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

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

" 54 | else: 55 | md = markdown.markdown(text, extensions=["extra"]) 56 | if config.css: 57 | md = "" + md 58 | md = pynliner.fromString(md) 59 | message = MIMEText(md, "html", _charset="UTF-8") 60 | return message 61 | 62 | 63 | def _move_headers(source, dest): 64 | for k, v in source.items(): 65 | # mutt sometimes sticks in a fake bcc header 66 | if k.lower() == "bcc": 67 | del source[k] 68 | elif not (k.startswith("Content-") or k.startswith("MIME")): 69 | dest.add_header(k, v) 70 | del source[k] 71 | 72 | 73 | def convert_tree(message, config, indent=0, wrap_alternative=True, charset=None): 74 | """Recursively convert a potentially-multipart tree. 75 | 76 | Returns a tuple of (the converted tree, whether any markdown was found) 77 | """ 78 | ct = message.get_content_type() 79 | cs = message.get_content_subtype() 80 | if charset is None: 81 | charset = get_charset_from_message_fragment(message) 82 | if not message.is_multipart(): 83 | # we're on a leaf 84 | converted = None 85 | disposition = message.get("Content-Disposition", "inline") 86 | if disposition == "inline" and ct in ("text/plain", "text/markdown"): 87 | converted = convert_one(message, config, charset) 88 | if converted is not None: 89 | if wrap_alternative: 90 | new_tree = MIMEMultipart("alternative") 91 | _move_headers(message, new_tree) 92 | new_tree.attach(message) 93 | new_tree.attach(converted) 94 | return new_tree, True 95 | else: 96 | return converted, True 97 | return message, False 98 | else: 99 | if ct == "multipart/signed": 100 | # if this is a multipart/signed message, then let's just 101 | # recurse into the non-signature part 102 | new_root = MIMEMultipart("alternative") 103 | if message.preamble: 104 | new_root.preamble = message.preamble 105 | _move_headers(message, new_root) 106 | converted = None 107 | for part in message.get_payload(): 108 | if part.get_content_type() != "application/pgp-signature": 109 | converted, did_conversion = convert_tree( 110 | part, 111 | config, 112 | indent=indent + 1, 113 | wrap_alternative=False, 114 | charset=charset, 115 | ) 116 | if did_conversion: 117 | new_root.attach(converted) 118 | new_root.attach(message) 119 | return new_root, did_conversion 120 | else: 121 | did_conversion = False 122 | new_root = MIMEMultipart(cs, message.get_charset()) 123 | if message.preamble: 124 | new_root.preamble = message.preamble 125 | _move_headers(message, new_root) 126 | for part in message.get_payload(): 127 | part, did_this_conversion = convert_tree( 128 | part, config, indent=indent + 1, charset=charset 129 | ) 130 | did_conversion |= did_this_conversion 131 | new_root.attach(part) 132 | return new_root, did_conversion 133 | 134 | 135 | def process_message(mail, config): 136 | converted, did_any_markdown = convert_tree(mail, config) 137 | if "Bcc" in converted: 138 | del converted["Bcc"] 139 | return converted 140 | 141 | 142 | def smtp_connection(c): 143 | """Create an SMTP connection from a Config object""" 144 | if c.smtp_ssl: 145 | klass = smtplib.SMTP_SSL 146 | else: 147 | klass = smtplib.SMTP 148 | conn = klass(c.smtp_host, c.smtp_port, timeout=c.smtp_timeout) 149 | if not c.smtp_ssl: 150 | conn.ehlo() 151 | conn.starttls() 152 | conn.ehlo() 153 | if c.smtp_username: 154 | conn.login(c.smtp_username, c.smtp_password) 155 | return conn 156 | 157 | 158 | def read_message(): 159 | return sys.stdin.read() 160 | 161 | 162 | def main(argv=None): 163 | parser = argparse.ArgumentParser(prog="muttdown") 164 | parser.add_argument( 165 | "-v", "--version", action="version", version="%s %s" % (__name__, __version__) 166 | ) 167 | parser.add_argument( 168 | "-c", 169 | "--config_file", 170 | default=os.path.expanduser("~/.muttdown.yaml"), 171 | type=argparse.FileType("r"), 172 | required=False, 173 | help="Path to YAML config file (default %(default)s)", 174 | ) 175 | parser.add_argument( 176 | "-p", 177 | "--print-message", 178 | action="store_true", 179 | help="Print the translated message to stdout instead of sending it", 180 | ) 181 | parser.add_argument("-f", "--envelope-from", required=True) 182 | parser.add_argument( 183 | "-s", 184 | "--sendmail-passthru", 185 | action="store_true", 186 | help="Pass mail through to sendmail for delivery", 187 | ) 188 | parser.add_argument("addresses", nargs="+") 189 | args = parser.parse_args(argv) 190 | 191 | c = config.Config() 192 | try: 193 | c.load(args.config_file) 194 | except config.ConfigError as e: 195 | sys.stderr.write("Error(s) in configuration %s:\n" % args.config_file.name) 196 | sys.stderr.write(" - %s\n" % e.message) 197 | sys.stderr.flush() 198 | return 1 199 | 200 | message = read_message() 201 | 202 | mail = email.message_from_string(message) 203 | 204 | rebuilt = process_message(mail, c) 205 | rebuilt.set_unixfrom(args.envelope_from) 206 | 207 | if args.print_message: 208 | print(rebuilt.as_string()) 209 | elif args.sendmail_passthru: 210 | cmd = c.sendmail.split() + ["-f", args.envelope_from] + args.addresses 211 | 212 | proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, shell=False) 213 | msg = rebuilt.as_string() 214 | msg = msg.encode("utf-8") 215 | proc.stdin.write(msg) 216 | proc.stdin.close() 217 | proc.wait() 218 | return proc.returncode 219 | else: 220 | conn = smtp_connection(c) 221 | msg = rebuilt.as_string() 222 | msg = msg.encode("utf-8") 223 | conn.sendmail(args.envelope_from, args.addresses, msg) 224 | conn.quit() 225 | return 0 226 | 227 | 228 | if __name__ == "__main__": 229 | sys.exit(main()) 230 | -------------------------------------------------------------------------------- /requirements-tests.txt: -------------------------------------------------------------------------------- 1 | pytest==8.* 2 | pytest-cov==5.* 3 | pytest-mock==3.* 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | with open("README.md", "r") as f: 4 | long_description = f.read() 5 | 6 | 7 | setup( 8 | name="muttdown", 9 | version="0.4.0", 10 | author="James Brown", 11 | author_email="roguelazer@roguelazer.com", 12 | url="https://github.com/Roguelazer/muttdown", 13 | license="ISC", 14 | packages=find_packages(exclude=["tests"]), 15 | keywords=["email"], 16 | description="Sendmail replacement that compiles markdown into HTML", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | install_requires=[ 20 | "Markdown>=3.0,<4.0", 21 | "PyYAML>=3.0", 22 | "pynliner==0.8.0", 23 | ], 24 | entry_points={ 25 | "console_scripts": [ 26 | "muttdown = muttdown.main:main", 27 | ] 28 | }, 29 | python_requires=">=3.6", 30 | classifiers=[ 31 | "Development Status :: 3 - Alpha", 32 | "Environment :: Console", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 3.6", 35 | "Programming Language :: Python :: 3.7", 36 | "Programming Language :: Python :: 3.8", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Intended Audience :: End Users/Desktop", 41 | "Operating System :: OS Independent", 42 | "License :: OSI Approved :: ISC License (ISCL)", 43 | "Topic :: Communications :: Email", 44 | ], 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Roguelazer/muttdown/52f70074dc68228c7a8672ed4e6929a5df309540/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICmDCCAYACCQC7muxZ8ym2UDANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV 3 | UzAgFw0xOTAyMDkwMzA0MDdaGA8yMTE5MDExNjAzMDQwN1owDTELMAkGA1UEBhMC 4 | VVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCa7UukN2cZcltM5ajH 5 | NVH7YeWGsiJjmobg1QQaf5AgCP1TC2WckN7cbmAp5nR8Ie2y8p4hu0dSziCLap6M 6 | txtTJJ87IoTkLvVv2ZIUJBJ00xD3SlEKNDv/532PTnObDUsRzdDRXcKW3PKqprFr 7 | kS1UKHgyR/U4pOENdRk5zN2Jkv5A/fWc2nkDwhYXInqW26WyxDJkamInRZF2iW6Y 8 | t88QMnHtEtNrf1rom4UvCPTmZqh/9Pm6uhIHi7/aanSSnCasOfKd1wUTuk3wAOzQ 9 | SxhVVEXPVHSI0yPQRDKm5O8VSSjRr0WC1ooljJarfrs8sLECTWpQOetlW2YlUjJf 10 | udUvAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJchLG3aIRnjcZU5unEu3icxVayi 11 | OiUWqLPTZsNc4vVKn+h8HP3TeTUSWUMHVp7OqmVvbMeLDZJm7JBMklr1RYBgeGi7 12 | HeJzG3q/9O5Ny1CZ3Rok/mxncZlDKKG0Z61aBD8rzkOooQFIa1KAPNdkfBbnfuir 13 | Lo83Y8Sy8OMA8yjatMxDt0sjhXJ++F83Tki9EMKEMhAcbY3nC5Df1c25va3O1IPH 14 | 5GkDb97lsey5UQ559XmHSBl+w8yZNRtN4k1Vq/aZuKCbyci9ogczU7eIjkawxhQ7 15 | X+O0ZgVqfLqrdCjnLYf7Gz3cEfYhaIfwQc9i6kOTyPZ/oQCz1E7Lf9KOI0c= 16 | -----END CERTIFICATE----- 17 | -------------------------------------------------------------------------------- /tests/data/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCa7UukN2cZcltM 3 | 5ajHNVH7YeWGsiJjmobg1QQaf5AgCP1TC2WckN7cbmAp5nR8Ie2y8p4hu0dSziCL 4 | ap6MtxtTJJ87IoTkLvVv2ZIUJBJ00xD3SlEKNDv/532PTnObDUsRzdDRXcKW3PKq 5 | prFrkS1UKHgyR/U4pOENdRk5zN2Jkv5A/fWc2nkDwhYXInqW26WyxDJkamInRZF2 6 | iW6Yt88QMnHtEtNrf1rom4UvCPTmZqh/9Pm6uhIHi7/aanSSnCasOfKd1wUTuk3w 7 | AOzQSxhVVEXPVHSI0yPQRDKm5O8VSSjRr0WC1ooljJarfrs8sLECTWpQOetlW2Yl 8 | UjJfudUvAgMBAAECggEAfm6G21XnSmn7vk5xpViLNfYXZQv8aoKR7euI9MMDcFFF 9 | wr67RsEnToa47ZjHmQHrRK0ghXCbbSUQhBYXm8hWgUySsaSjBMCZxZSt1Mf3U+Vn 10 | pBe++O/Vwyo8WnXwfCmmCLqI3kOA6LMZSlDM23bXoiWAqa/1nCtaCix00KmyZXEN 11 | 9x/2OKLorIRn3ewhY+kplhYt/P6MUlPUpPeX1RBMoF31iPFNcSiwwZSZjsK/nUI5 12 | MnZJT4NxcmZfBrP7RUBHZy5WFOlKQ0KO+zlYjXN+R0ZGuCvFAnuDtK37/BHQYc51 13 | gAqcdLCKnjOF00IhQNGnorEKMYvqIIgVIv39bGp2CQKBgQDLBPaE4Zz/CFL/Hqum 14 | mlzMk/Oy+cixZJ3LYnLlptnHKTu8PkzoAH0fSRfRYuOBLuYhhhP3iCqnPBAQ0yu+ 15 | 48nMothMrbe4OCe+kUiQ/9VUyRLwr1sh0yFcZJBawstcpOX7YTfgJS3qtvQS4rJG 16 | 1FDrekBpz3Sgj1us5vya2e/7WwKBgQDDW3BZxuZMGJkeZdKpYrqp2hDrdn/t5lkb 17 | TKs9fbEf8SMcQSlYJvoCjmy55NCW04TEFnPSwj/XNhwOPNDxurHMxy3+vfKbchsq 18 | 4vnqowpBCdZ1Q7osS9xlzqvLPqET3WXATtcYoYXMjSq5o5tzwcmiSoQnkDnaAnXZ 19 | HeyD4aI5vQKBgBLTQfyuYv1vCysm7+nB9IrvyTA2Yzq3xr3+QgMzhowmMajR6hW1 20 | PeTxxSigT9JBxAslwKI6WSIqup6kxjCsNKEqFH5/uUJ2ypCsLhtr7Z8wCfaRfBTV 21 | 3AkSNiSEXZEYpU67BBBfwjM6hcVeigNxWpOLQX/OQdVFlc2hmZjOTqdzAoGASRN0 22 | RHDthruQ01kdYzVGQ/EJcTrjgdcvr9GPILJaxmsKSjBpycrSrJAgRa09BZ5bxInt 23 | i4IUJWndNsozEqlWhxZeszLUhKc7WGCNQeL5G/kVGspZ4uYBrKeRhbaIxIiF3ljf 24 | hxwsk6aeu9BifvuXdDjRlIcTzOQstynFZlPJvjUCgYBi7b/aCGtACAYL0wI/l4VJ 25 | iHRghC7/E/wm6Fdu4DT3lUAFYyzV2X30e9Hy9RKGxRB4CHUA4hJ3TIjhCtrg0fA3 26 | +StWD+BqCO8NhiPOQzdCDsElsefMeS70iNg2+vxXn6Vw2QcW0d5G1bz44zH4DVuL 27 | mLrBMIq8BZ6qJ8OrylCoMg== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import email.message 4 | import os 5 | import select 6 | import shutil 7 | import socket 8 | import ssl 9 | import sys 10 | import tempfile 11 | import threading 12 | import time 13 | from email.message import Message 14 | from email.mime.application import MIMEApplication 15 | from email.mime.multipart import MIMEMultipart 16 | from email.mime.text import MIMEText 17 | 18 | import pytest 19 | import yaml 20 | 21 | from muttdown import main 22 | from muttdown.config import Config 23 | from muttdown.main import convert_tree, process_message 24 | 25 | 26 | @pytest.fixture 27 | def basic_config(): 28 | return Config() 29 | 30 | 31 | @pytest.fixture 32 | def tempdir(): 33 | # workaround because pytest's bultin tmpdir fixture is broken on python 3.3 34 | dirname = tempfile.mkdtemp() 35 | try: 36 | yield dirname 37 | finally: 38 | shutil.rmtree(dirname) 39 | 40 | 41 | @pytest.fixture 42 | def config_with_css(tempdir): 43 | with open("%s/test.css" % tempdir, "w") as f: 44 | f.write("html, body, p { font-family: serif; }\n") 45 | c = Config() 46 | c.merge_config({"css_file": "%s/test.css" % tempdir}) 47 | return c 48 | 49 | 50 | def test_unmodified_no_match(basic_config): 51 | msg = Message() 52 | msg["Subject"] = "Test Message" 53 | msg["From"] = "from@example.com" 54 | msg["To"] = "to@example.com" 55 | msg["Bcc"] = "bananas" 56 | msg.set_payload("This message has no sigil") 57 | 58 | converted = process_message(msg, basic_config) 59 | assert converted == msg 60 | 61 | 62 | def test_simple_message(basic_config): 63 | msg = MIMEMultipart() 64 | msg["Subject"] = "Test Message" 65 | msg["From"] = "from@example.com" 66 | msg["To"] = "to@example.com" 67 | msg["Bcc"] = "bananas" 68 | msg.preamble = "Outer preamble" 69 | 70 | msg.attach(MIMEText("!m This is the main message body")) 71 | 72 | attachment = MIMEText("this is an attachment", "x-misc") 73 | attachment.add_header("Content-Disposition", "attachment") 74 | msg.attach(attachment) 75 | 76 | converted, _ = convert_tree(msg, basic_config) 77 | assert converted["Subject"] == "Test Message" 78 | assert converted["From"] == "from@example.com" 79 | assert converted["To"] == "to@example.com" 80 | assert converted.get("Bcc", None) is None 81 | assert isinstance(converted, MIMEMultipart) 82 | assert converted.preamble == "Outer preamble" 83 | assert len(converted.get_payload()) == 2 84 | alternatives_part = converted.get_payload()[0] 85 | assert isinstance(alternatives_part, MIMEMultipart) 86 | assert alternatives_part.get_content_type() == "multipart/alternative" 87 | assert len(alternatives_part.get_payload()) == 2 88 | text_part = alternatives_part.get_payload()[0] 89 | html_part = alternatives_part.get_payload()[1] 90 | assert isinstance(text_part, MIMEText) 91 | assert text_part.get_content_type() == "text/plain" 92 | assert isinstance(html_part, MIMEText) 93 | assert html_part.get_content_type() == "text/html" 94 | attachment_part = converted.get_payload()[1] 95 | assert isinstance(attachment_part, MIMEText) 96 | assert attachment_part["Content-Disposition"] == "attachment" 97 | assert attachment_part.get_content_type() == "text/x-misc" 98 | 99 | 100 | def test_with_css(config_with_css): 101 | msg = Message() 102 | msg["Subject"] = "Test Message" 103 | msg["From"] = "from@example.com" 104 | msg["To"] = "to@example.com" 105 | msg["Bcc"] = "bananas" 106 | msg.set_payload("!m\n\nThis is a message") 107 | 108 | converted, _ = convert_tree(msg, config_with_css) 109 | assert isinstance(converted, MIMEMultipart) 110 | assert len(converted.get_payload()) == 2 111 | text_part = converted.get_payload()[0] 112 | assert text_part.get_payload(decode=True) == b"!m\n\nThis is a message" 113 | html_part = converted.get_payload()[1] 114 | assert ( 115 | html_part.get_payload(decode=True) 116 | == b'

This is a message

' 117 | ) 118 | 119 | 120 | def test_fenced(basic_config): 121 | msg = Message() 122 | msg["Subject"] = "Test Message" 123 | msg["From"] = "from@example.com" 124 | msg["To"] = "to@example.com" 125 | msg["Bcc"] = "bananas" 126 | msg.preamble = "Outer preamble" 127 | 128 | msg.set_payload("!m This is the main message body\n\n```\nsome code\n```\n") 129 | 130 | converted, _ = convert_tree(msg, basic_config) 131 | assert isinstance(converted, MIMEMultipart) 132 | assert len(converted.get_payload()) == 2 133 | html_part = converted.get_payload()[1] 134 | assert ( 135 | html_part.get_payload(decode=True) 136 | == b"

This is the main message body

\n
some code\n
" 137 | ) 138 | 139 | 140 | def test_headers_when_multipart_signed(basic_config): 141 | msg = MIMEMultipart("signed") 142 | msg["Subject"] = "Test Message" 143 | msg["From"] = "from@example.com" 144 | msg["To"] = "to@example.com" 145 | msg["Bcc"] = "bananas" 146 | msg.preamble = "Outer preamble" 147 | 148 | msg.attach(MIMEText("!m This is the main message body")) 149 | msg.attach(MIMEApplication("signature here", "pgp-signature", name="signature.asc")) 150 | 151 | converted, _ = convert_tree(msg, basic_config) 152 | 153 | assert converted["Subject"] == "Test Message" 154 | assert converted["From"] == "from@example.com" 155 | assert converted["To"] == "to@example.com" 156 | 157 | assert isinstance(converted, MIMEMultipart) 158 | assert converted.preamble == "Outer preamble" 159 | assert len(converted.get_payload()) == 2 160 | assert converted.get_content_type() == "multipart/alternative" 161 | html_part = converted.get_payload()[0] 162 | original_signed_part = converted.get_payload()[1] 163 | assert isinstance(html_part, MIMEText) 164 | assert html_part.get_content_type() == "text/html" 165 | assert isinstance(original_signed_part, MIMEMultipart) 166 | assert original_signed_part.get_content_type() == "multipart/signed" 167 | assert original_signed_part["Subject"] is None 168 | text_part = original_signed_part.get_payload()[0] 169 | signature_part = original_signed_part.get_payload()[1] 170 | assert text_part.get_content_type() == "text/plain" 171 | assert signature_part.get_content_type() == "application/pgp-signature" 172 | 173 | 174 | class MockSmtpServer(object): 175 | def __init__(self): 176 | self._s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 177 | self._s.bind(("127.0.0.1", 0)) 178 | self.address = self._s.getsockname()[0:2] 179 | self._t = None 180 | self._started = threading.Event() 181 | self.messages = [] 182 | self.running = False 183 | 184 | def start(self): 185 | self._t = threading.Thread(target=self.run) 186 | self._t.start() 187 | if self._started.wait(5) is not True: 188 | raise ValueError("SMTP Server Thread failed to start!") 189 | 190 | def run(self): 191 | if hasattr(ssl, "create_default_context"): 192 | context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 193 | else: 194 | context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) 195 | context.load_cert_chain( 196 | certfile="tests/data/cert.pem", keyfile="tests/data/key.pem" 197 | ) 198 | self._s.listen(128) 199 | self._started.set() 200 | self.running = True 201 | while self.running: 202 | r, _, x = select.select([self._s], [self._s], [self._s], 0.5) 203 | if r: 204 | start = time.time() 205 | conn, addr = self._s.accept() 206 | conn = context.wrap_socket(conn, server_side=True) 207 | message = b"" 208 | conn.sendall(b"220 localhost SMTP Fake\r\n") 209 | message += conn.recv(1024) 210 | conn.sendall(b"250-localhost\r\n250 DSN\r\n") 211 | # MAIL FROM 212 | message += conn.recv(1024) 213 | conn.sendall(b"250 2.1.0 Ok\r\n") 214 | # RCPT TO 215 | message += conn.recv(1024) 216 | conn.sendall(b"250 2.1.0 Ok\r\n") 217 | # DATA 218 | message += conn.recv(6) 219 | conn.sendall(b"354 End data with .\r\n") 220 | while time.time() < start + 5: 221 | chunk = conn.recv(4096) 222 | if not chunk: 223 | break 224 | message += chunk 225 | if b"\r\n.\r\n" in message: 226 | break 227 | conn.sendall(b"250 2.1.0 Ok\r\n") 228 | message += conn.recv(1024) 229 | conn.sendall(b"221 Bye\r\n") 230 | conn.close() 231 | self.messages.append((addr, message)) 232 | 233 | def stop(self): 234 | if self._t is not None: 235 | self.running = False 236 | self._t.join() 237 | 238 | 239 | @pytest.fixture 240 | def smtp_server(): 241 | s = MockSmtpServer() 242 | s.start() 243 | try: 244 | yield s 245 | finally: 246 | s.stop() 247 | 248 | 249 | def test_main_smtplib(tempdir, smtp_server, mocker): 250 | config_path = os.path.join(tempdir, "config.yaml") 251 | with open(config_path, "w") as f: 252 | yaml.dump( 253 | { 254 | "smtp_host": smtp_server.address[0], 255 | "smtp_port": smtp_server.address[1], 256 | "smtp_ssl": True, 257 | }, 258 | f, 259 | ) 260 | msg = Message() 261 | msg["Subject"] = "Test Message" 262 | msg["From"] = "from@example.com" 263 | msg["To"] = "to@example.com" 264 | msg["Bcc"] = "bananas" 265 | msg.set_payload("This message has no sigil") 266 | mocker.patch.object(main, "read_message", return_value=msg.as_string()) 267 | main.main(["-c", config_path, "-f", "from@example.com", "to@example.com"]) 268 | 269 | assert len(smtp_server.messages) == 1 270 | attr, transcript = smtp_server.messages[0] 271 | assert b"Subject: Test Message" in transcript 272 | assert b"no sigil" in transcript 273 | 274 | 275 | def test_main_passthru(tempdir, mocker): 276 | output_path = os.path.join(tempdir, "output") 277 | sendmail_path = os.path.join(tempdir, "sendmail") 278 | with open(sendmail_path, "w") as f: 279 | f.write("#!{0}\n".format(sys.executable)) 280 | f.write("import sys\n") 281 | f.write('output_path = "{0}"\n'.format(output_path)) 282 | f.write('open(output_path, "w").write(sys.stdin.read())\n') 283 | f.write("sys.exit(0)") 284 | os.chmod(sendmail_path, 0o750) 285 | config_path = os.path.join(tempdir, "config.yaml") 286 | with open(config_path, "w") as f: 287 | yaml.dump({"sendmail": sendmail_path}, f) 288 | 289 | msg = Message() 290 | msg["Subject"] = "Test Message" 291 | msg["From"] = "from@example.com" 292 | msg["To"] = "to@example.com" 293 | msg["Bcc"] = "bananas" 294 | msg.set_payload("This message has no sigil") 295 | mocker.patch.object(main, "read_message", return_value=msg.as_string()) 296 | main.main(["-c", config_path, "-f", "from@example.com", "-s", "to@example.com"]) 297 | 298 | with open(output_path, "rb") as f: 299 | transcript = f.read() 300 | assert b"Subject: Test Message" in transcript 301 | assert b"no sigil" in transcript 302 | 303 | 304 | def test_raw_unicode(basic_config): 305 | 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 306 | mail = email.message_from_string(raw_message.decode("utf-8")) 307 | converted = process_message(mail, basic_config) 308 | assert converted["From"] == "Test " 309 | assert "Ø" in converted.get_payload() 310 | 311 | 312 | def test_assume_markdown(basic_config): 313 | msg = Message() 314 | msg["Subject"] = "Test Message" 315 | msg["From"] = "from@example.com" 316 | msg["To"] = "to@example.com" 317 | msg["Bcc"] = "bananas" 318 | msg.set_payload("This message has no **sigil**") 319 | 320 | basic_config.merge_config({"assume_markdown": True}) 321 | 322 | converted = process_message(msg, basic_config) 323 | html_part = converted.get_payload()[1].get_payload(decode=True) 324 | assert html_part == b"

This message has no sigil

" 325 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | from muttdown.config import Config 4 | 5 | 6 | def test_smtp_password_literal(): 7 | c = Config() 8 | c.merge_config({"smtp_password": "foo"}) 9 | assert c.smtp_password == "foo" 10 | 11 | 12 | def test_smtp_password_command(): 13 | c = Config() 14 | c.merge_config({"smtp_password_command": 'sh -c "echo foo"'}) 15 | assert c.smtp_password == "foo" 16 | 17 | 18 | def test_css(): 19 | c = Config() 20 | c.merge_config({"css_file": None}) 21 | assert c.css == "" 22 | 23 | with tempfile.NamedTemporaryFile(delete=True) as css_file: 24 | css_file.write(b"html { background-color: black; }\n") 25 | css_file.flush() 26 | c.merge_config({"css_file": css_file.name}) 27 | assert c.css == "html { background-color: black; }\n" 28 | 29 | 30 | def test_assume_markdown(): 31 | c = Config() 32 | assert not c.assume_markdown 33 | c.merge_config({"assume_markdown": True}) 34 | assert c.assume_markdown 35 | --------------------------------------------------------------------------------