#
# Configuration:
# neomuttrc (needs to be a single line):
+# set my_mdwn_extensions="extra,admonition,codehilite,sane_lists,smarty"
# macro compose B "\
-# <enter-command> source '$my_confdir/buildmimetree.py setup|'<enter>\
+# <enter-command> source '$my_confdir/buildmimetree.py \
+# --tempdir $tempdir --extensions $my_mdwn_extensions|'<enter>\
# <enter-command> sourc e \$my_mdwn_postprocess_cmd_file<enter>\
# " "Convert message into a modern MIME tree with inline images"
#
"Released under the MIT licence"
)
- subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
- parser_setup = subp.add_parser("setup", help="Setup phase")
- parser_massage = subp.add_parser("massage", help="Massaging phase")
-
- parser_setup.add_argument(
- "--debug-commands",
- action="store_true",
- help="Turn on debug logging of commands generated to stderr",
+ parser.add_argument(
+ "--extensions",
+ type=str,
+ default="",
+ help="Markdown extension to use (comma-separated list)"
)
- parser_setup.add_argument(
- "--extension",
- "-x",
- metavar="EXTENSION",
- dest="extensions",
- nargs="?",
- default=[],
- action="append",
- help="Markdown extension to add to the list of extensions use",
+ parser.add_argument(
+ "--only-build",
+ action="store_true",
+ help="Only build, don't send the message",
)
- parser_setup.add_argument(
- "--send-message",
- action="store_true",
- help="Generate command(s) to send the message after processing",
+ parser.add_argument(
+ "--tempdir",
+ default=None,
+ help="Specify temporary directory to use for attachments",
)
- parser_massage.add_argument(
+ parser.add_argument(
"--debug-commands",
action="store_true",
help="Turn on debug logging of commands generated to stderr",
)
- parser_massage.add_argument(
- "--debug-walk",
- action="store_true",
- help="Turn on debugging to stderr of the MIME tree walk",
- )
-
- parser_massage.add_argument(
- "--extensions",
- metavar="EXTENSIONS",
- type=str,
- default="",
- help="Markdown extension to use (comma-separated list)",
- )
+ subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
+ massage_p = subp.add_parser("massage", help="Massaging phase (internal use)")
- parser_massage.add_argument(
+ massage_p.add_argument(
"--write-commands-to",
metavar="PATH",
dest="cmdpath",
help="Temporary file path to write commands to",
)
- parser_massage.add_argument(
+ massage_p.add_argument(
+ "--debug-walk",
+ action="store_true",
+ help="Turn on debugging to stderr of the MIME tree walk",
+ )
+
+ massage_p.add_argument(
"MAILDRAFT",
nargs="?",
help="If provided, the script is invoked as editor on the mail draft",
mimetype = data.headers["Content-Type"]
ext = mimetypes.guess_extension(mimetype)
- tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)[
- 1
- ]
- path = pathlib.Path(tempfilename)
+ tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
+ path = pathlib.Path(tempfilename[1])
filewriter_fn(path, data.read(), "w+b")
relparts.append(
- Part(
- *mimetype.split("/"), path, cid=info.cid, desc=info.desc
- )
+ Part(*mimetype.split("/"), path, cid=info.cid, desc=f"Image: {info.desc}")
)
return relparts
def convert_markdown_to_html(
- origtext, draftpath, *, filewriter_fn=filewriter_fn, extensions=None
+ origtext,
+ draftpath,
+ *,
+ filewriter_fn=filewriter_fn,
+ tempdir=None,
+ extensions=None,
):
origtext, htmltext, images = markdown_with_inline_image_support(
origtext, extensions=extensions
"alternative", [textpart, htmlpart], "Group of alternative content"
)
- imgparts = collect_inline_images(images, filewriter_fn=filewriter_fn)
+ imgparts = collect_inline_images(
+ images, tempdir=tempdir, filewriter_fn=filewriter_fn
+ )
if imgparts:
return Multipart(
"relative", [altpart] + imgparts, "Group of related content"
def do_setup(
- extensions=None, *, out_f=sys.stdout, temppath=None, debug_commands=False
+ *,
+ out_f=sys.stdout,
+ temppath=None,
+ tempdir=None,
+ debug_commands=False,
):
- extensions = extensions or []
temppath = temppath or pathlib.Path(
- tempfile.mkstemp(prefix="muttmdwn-")[1]
+ tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
)
cmds = MuttCommands(out_f, debug=debug_commands)
- editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
- if extensions:
- editor = f'{editor} --extensions {",".join(extensions)}'
- if debug_commands:
- editor = f'{editor} --debug-commands'
+ editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
cmds.cmd('set my_editor="$editor"')
cmds.cmd('set my_edit_headers="$edit_headers"')
*,
extensions=None,
converter=convert_markdown_to_html,
+ only_build=False,
+ tempdir=None,
debug_commands=False,
debug_walk=False,
):
cmds.flush()
extensions = extensions.split(",") if extensions else []
- tree = converter(draft_f.read(), draftpath, extensions=extensions)
+ tree = converter(draft_f.read(), draftpath, tempdir=tempdir, extensions=extensions)
mimetree = MIMETreeDFWalker(debug=debug_walk)
depth-first, and responsible for telling NeoMutt how to assemble
the tree.
"""
+ KILL_LINE=r'\Ca\Ck'
+
if isinstance(item, Part):
# We've hit a leaf-node, i.e. an alternative or a related part
# with actual content.
# information, then we might just as well update the NeoMutt
# tree now:
if item.cid:
- cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
+ cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
elif isinstance(item, Multipart):
# This node has children, but we already visited them (see
# appropriate grouping:
if item.subtype == "alternative":
cmds.push("<group-alternatives>")
- elif item.subtype == "relative":
+ elif item.subtype in ("relative", "related"):
cmds.push("<group-related>")
elif item.subtype == "multilingual":
cmds.push("<group-multilingual>")
# If the item has a description, we might just as well add it
if item.desc:
- cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
+ cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
# Finally, if we're at non-root level, tag the new container,
# as it might itself be part of a container, to be processed
# function
mimetree.walk(tree, visitor_fn=visitor_fn)
+ if not only_build:
+ cmds.push("<send-message>")
+
# Finally, cleanup. Since we're responsible for removing the temporary
# file, how's this for a little hack?
try:
if __name__ == "__main__":
args = parse_cli_args()
- if args.mode == "setup":
- if args.send_message:
- raise NotImplementedError()
-
- do_setup(args.extensions, debug_commands=args.debug_commands)
+ if args.mode is None:
+ do_setup(
+ tempdir=args.tempdir,
+ debug_commands=args.debug_commands,
+ )
elif args.mode == "massage":
with open(args.MAILDRAFT, "r") as draft_f, open(
pathlib.Path(args.MAILDRAFT),
cmd_f,
extensions=args.extensions,
+ only_build=args.only_build,
+ tempdir=args.tempdir,
debug_commands=args.debug_commands,
debug_walk=args.debug_walk,
)
mimetree.walk(basic_mime_tree)
assert len(items) == 5
- def test_do_setup_no_extensions(self, const1, capsys):
- "Assert basics about the setup command output"
- do_setup(temppath=const1, out_f=sys.stdout)
- captout = capsys.readouterr()
- lines = captout.out.splitlines()
- assert lines[2].endswith(f'{const1}"')
- assert lines[4].endswith(const1)
- assert "first-entry" in lines[-1]
- assert "edit-file" in lines[-1]
-
- def test_do_setup_extensions(self, const1, const2, capsys):
- "Assert that extensions are passed to editor"
- do_setup(
- temppath=const1, extensions=[const2, const1], out_f=sys.stdout
- )
- captout = capsys.readouterr()
- lines = captout.out.splitlines()
- # assert comma-separated list of extensions passed
- assert lines[2].endswith(f'{const2},{const1}"')
- assert lines[4].endswith(const1)
-
@pytest.fixture
def string_io(self, const1, text=None):
return StringIO(text or const1)
def test_do_massage_basic(self, const1, string_io, capsys):
- def converter(drafttext, draftpath, extensions):
+ def converter(drafttext, draftpath, extensions, tempdir):
return Part("text", "plain", draftpath, orig=True)
do_massage(
assert '="$my_edit_headers"' in lines.pop(0)
assert "unset my_editor" == lines.pop(0)
assert "unset my_edit_headers" == lines.pop(0)
+ assert "send-message" in lines.pop(0)
assert "update-encoding" in lines.pop(0)
assert "source 'rm -f " in lines.pop(0)
assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
def test_do_massage_fulltree(
self, string_io, const1, basic_mime_tree, capsys
):
- def converter(drafttext, draftpath, extensions):
+ def converter(drafttext, draftpath, extensions, tempdir):
return basic_mime_tree
do_massage(
captured = capsys.readouterr()
lines = captured.out.splitlines()[4:]
+ assert "send-message" in lines.pop(0)
assert "Related" in lines.pop(0)
assert "group-related" in lines.pop(0)
assert "tag-entry" in lines.pop(0)
assert k == f"file://{imgpath}"
break
- def test_markdown_inline_image_processor_base64(self):
- img = (
+ @pytest.fixture
+ def test_png(self):
+ return (
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
"AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
)
- text = f"![1px white inlined]({img})"
+
+ def test_markdown_inline_image_processor_base64(self, test_png):
+ text = f"![1px white inlined]({test_png})"
text, html, images = markdown_with_inline_image_support(text)
assert 'src="cid:' in html
assert "](cid:" in text
assert len(images) == 1
- assert img in images
+ assert test_png in images
def test_converter_tree_inline_image_base64(
- self, const1, fake_filewriter
+ self, test_png, const1, fake_filewriter
):
- img = (
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
- "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
- )
- text = f"![inline base64 image]({img})"
+ text = f"![inline base64 image]({test_png})"
path = pathlib.Path(const1)
tree = convert_markdown_to_html(
text, path, filewriter_fn=fake_filewriter
assert tree.children[1].subtype == "png"
written = fake_filewriter.pop()
assert tree.children[1].path == written[0]
- assert written[1] == request.urlopen(img).read()
+ assert written[1] == request.urlopen(test_png).read()
+
+ def test_inline_image_collection(
+ self, test_png, const1, const2, fake_filewriter
+ ):
+ test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
+ relparts = collect_inline_images(
+ test_images, filewriter_fn=fake_filewriter
+ )
+
+ written = fake_filewriter.pop()
+ assert b"PNG" in written[1]
+
+ assert relparts[0].subtype == "png"
+ assert relparts[0].path == written[0]
+ assert relparts[0].cid == const1
+ assert relparts[0].desc.endswith(const2)
except ImportError:
pass