pax_global_header00006660000000000000000000000064125507635060014523gustar00rootroot0000000000000052 comment=12b82d588828c976cd6f68d5ae5820085855419f muttdown-muttdown-0.2/000077500000000000000000000000001255076350600151245ustar00rootroot00000000000000muttdown-muttdown-0.2/.gitignore000066400000000000000000000000531255076350600171120ustar00rootroot00000000000000*.pyc env/ build/ dist/ sdist/ *.egg-info/ muttdown-muttdown-0.2/LICENSE.txt000066400000000000000000000013601255076350600167470ustar00rootroot00000000000000Copyright (c) 2015 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.2/MANIFEST.in000066400000000000000000000000331255076350600166560ustar00rootroot00000000000000include *.txt include *.md muttdown-muttdown-0.2/README.md000066400000000000000000000057771255076350600164230ustar00rootroot00000000000000muttdown ======== `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 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 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`. 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.2/muttdown/000077500000000000000000000000001255076350600170055ustar00rootroot00000000000000muttdown-muttdown-0.2/muttdown/__init__.py000066400000000000000000000001671255076350600211220ustar00rootroot00000000000000version_info = (0, 2) __version__ = '.'.join(map(str, version_info)) __author__ = 'James Brown ' muttdown-muttdown-0.2/muttdown/config.py000066400000000000000000000063731255076350600206350ustar00rootroot00000000000000import copy import yaml import subprocess import os.path # largely copied from my earlier work in fakemtpd if hasattr(subprocess, 'check_output'): check_output = subprocess.check_output else: def check_output(*args, **kwargs): kwargs['stdout'] = subprocess.PIPE p = subprocess.Popen(*args, **kwargs) stdout, _ = p.communicate() assert p.returncode == 0 return stdout 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.iterkeys(): 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) class Config(object): __metaclass__ = _ParamsAsProps _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', } 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._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: self._css = open(os.path.expanduser(self.css_file), 'r').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).rstrip('\n') else: return self._config['smtp_password'] muttdown-muttdown-0.2/muttdown/debug.py000066400000000000000000000001431255076350600204430ustar00rootroot00000000000000import sys import email.iterators email.iterators._structure(email.message_from_file(sys.stdin)) muttdown-muttdown-0.2/muttdown/main.py000066400000000000000000000121171255076350600203050ustar00rootroot00000000000000from __future__ import print_function import argparse import sys import smtplib import re import os.path import email import email.iterators from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import subprocess import markdown import pynliner from . import config def convert_one(part, config): try: text = part.get_payload(None, True) if not text.startswith('!m'): return None text = re.sub('\s*!m\s*', '', text, re.M) if '\n-- \n' in text: pre_signature, signature = text.split('\n-- \n') md = markdown.markdown(pre_signature, output_format="html5") md += '\n

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

' else: md = markdown.markdown(text) if config.css: md = '' + md md = pynliner.fromString(md) message = MIMEText(md, 'html') return message except Exception: return None def convert_tree(message, config): """Recursively convert a potentially-multipart tree. Returns a tuple of (the converted tree, whether any markdown was found) """ ct = message.get_content_type() if message.is_multipart(): if ct == 'multipart/signed': # if this is a multipart/signed message, then let's just # recurse into the non-signature part for part in message.get_payload(): if part.get_content_type() != 'application/pgp-signature': return convert_tree(part, config) else: # it's multipart, but not signed. copy it! new_root = MIMEMultipart(message.get_content_subtype(), message.get_charset()) did_conversion = False for part in message.get_payload(): converted_part, this_did_conversion = convert_tree(part, config) did_conversion |= this_did_conversion new_root.attach(converted_part) return new_root, did_conversion else: # okay, this isn't a multipart type. If it's inline # and it's either text/plain or text/markdown, let's convert it converted = None disposition = message.get('Content-Disposition', 'inline') if disposition == 'inline' and ct in ('text/plain', 'text/markdown'): converted = convert_one(message, config) if converted is not None: return converted, True return message, False def rebuild_multipart(mail, config): converted, did_any_markdown = convert_tree(mail, config) if did_any_markdown: new_top = MIMEMultipart('alternative') for k, v in mail.items(): # the fake Bcc header definitely shouldn't keep existing if k.lower() == 'bcc': del mail[k] elif not (k.startswith('Content-') or k.startswith('MIME')): new_top.add_header(k, v) del mail[k] new_top.attach(mail) new_top.attach(converted) return new_top else: return mail 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() if c.smtp_username: conn.login(c.smtp_username, c.smtp_password) return conn def main(): parser = argparse.ArgumentParser() parser.add_argument( '-c', '--config_file', default=os.path.expanduser('~/.muttdown.yaml'), type=argparse.FileType('r'), required=True, 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() c = config.Config() try: c.load(args.config_file) except config.ConfigError as e: print('Error(s) in configuration %s:' % args.config_file.name) print(' - ' + e.message) return 1 message = sys.stdin.read() mail = email.message_from_string(message) rebuilt = rebuild_multipart(mail, c) rebuilt.set_unixfrom(args.envelope_from) if args.print_message: print(rebuilt.as_string()) elif args.sendmail_passthru: cmd = [c.sendmail, '-f', args.envelope_from] + args.addresses proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, shell=False) proc.communicate(rebuilt.as_string()) else: conn = smtp_connection(c) conn.sendmail(args.envelope_from, args.addresses, rebuilt.as_string()) conn.quit() if __name__ == '__main__': main() muttdown-muttdown-0.2/requirements.txt000066400000000000000000000000521255076350600204050ustar00rootroot00000000000000Markdown>=2.0 PyYAML>=3.0 pynliner==0.5.2 muttdown-muttdown-0.2/setup.cfg000066400000000000000000000000351255076350600167430ustar00rootroot00000000000000[flake8] max-line-length=120 muttdown-muttdown-0.2/setup.py000066400000000000000000000046561255076350600166510ustar00rootroot00000000000000#!/usr/bin/env python import collections from setuptools import setup, find_packages import pip from pip.req import parse_requirements def _version_tuple(version_string): return tuple( (int(component) if all(x.isdigit() for x in component) else component) for component in version_string.split('.') ) def get_install_requirements(): ReqOpts = collections.namedtuple( 'ReqOpts', ['skip_requirements_regex', 'default_vcs', 'isolated_mode'] ) opts = ReqOpts(None, 'git', False) requires = [] dependency_links = [] req_args = ['requirements.txt'] req_kwargs = {'options': opts} pip_version_info = _version_tuple(pip.__version__) if pip_version_info >= (6, 0): from pip.download import PipSession session = PipSession() req_kwargs['session'] = session for ir in parse_requirements(*req_args, **req_kwargs): if ir is not None: if pip_version_info >= (6, 0): if ir.link is not None: dependency_links.append(str(ir.url)) else: if ir.url is not None: dependency_links.append(str(ir.url)) if ir.req is not None: requires.append(str(ir.req)) return requires, dependency_links install_requires, dependency_links = get_install_requirements() setup( name="muttdown", version="0.2", author="James Brown", author_email="Roguelazer@gmail.com", url="https://github.com/Roguelazer/muttdown", license="ISC", packages=find_packages(exclude=['tests']), keywords=["email"], description="Sendmail replacement that compiles markdown into HTML", install_requires=install_requires, dependency_links=dependency_links, test_suite="nose.collector", entry_points={ 'console_scripts': [ 'muttdown = muttdown.main:main', ] }, classifiers=[ "Development Status :: 3 - Alpha", "Environment :: Console", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Intended Audience :: End Users/Desktop", "Operating System :: OS Independent", "License :: OSI Approved :: ISC License (ISCL)", "Topic :: Communications :: Email", ] )