]> git.madduck.net Git - etc/neomutt.git/blob - .config/neomutt/buildmimetree.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

f4ee8216439436c3cc43310e30b31beffadaae73
[etc/neomutt.git] / .config / neomutt / buildmimetree.py
1 #!/usr/bin/python3
2 #
3 # NeoMutt helper script to create multipart/* emails with Markdown → HTML
4 # alternative conversion, and handling of inline images, using NeoMutt's
5 # ability to manually craft MIME trees, but automating this process.
6 #
7 # Configuration:
8 #   neomuttrc (needs to be a single line):
9 #     macro compose B "\
10 #       <enter-command> source '$my_confdir/buildmimetree.py setup --tempdir $tempdir|'<enter>\
11 #       <enter-command> sourc e \$my_mdwn_postprocess_cmd_file<enter>\
12 #     " "Convert message into a modern MIME tree with inline images"
13 #
14 #     (Yes, we need to call source twice, as mutt only starts to process output
15 #     from a source command when the command exits, and since we need to react
16 #     to the output, we need to be invoked again, using a $my_ variable to pass
17 #     information)
18 #
19 # Requirements:
20 #   - python3
21 #   - python3-markdown
22 # Optional:
23 #   - pytest
24 #   - Pynliner
25 #   - Pygments, if installed, then syntax highlighting is enabled
26 #
27 # Latest version:
28 #   https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
29 #
30 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
31 # Released under the GPL-2+ licence, just like Mutt itself.
32 #
33
34 import sys
35 import pathlib
36 import markdown
37 import tempfile
38 import argparse
39 import re
40 import mimetypes
41 from collections import namedtuple, OrderedDict
42 from markdown.extensions import Extension
43 from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
44 from email.utils import make_msgid
45 from urllib import request
46
47
48 def parse_cli_args(*args, **kwargs):
49     parser = argparse.ArgumentParser(
50         description=(
51             "NeoMutt helper to turn text/markdown email parts "
52             "into full-fledged MIME trees"
53         )
54     )
55     parser.epilog = (
56         "Copyright © 2022 martin f. krafft <madduck@madduck.net>.\n"
57         "Released under the MIT licence"
58     )
59
60     subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
61     parser_setup = subp.add_parser("setup", help="Setup phase")
62     parser_massage = subp.add_parser("massage", help="Massaging phase")
63
64     parser_setup.add_argument(
65         "--extension",
66         "-x",
67         metavar="EXTENSION",
68         dest="extensions",
69         nargs="?",
70         default=[],
71         action="append",
72         help="Markdown extension to add to the list of extensions use",
73     )
74
75     parser_setup.add_argument(
76         "--only-build",
77         action="store_true",
78         help="Only build, don't send the message",
79     )
80
81     parser_setup.add_argument(
82         "--tempdir",
83         default=None,
84         help="Specify temporary directory to use for attachments",
85     )
86
87     parser_setup.add_argument(
88         "--debug-commands",
89         action="store_true",
90         help="Turn on debug logging of commands generated to stderr",
91     )
92
93     parser_massage.add_argument(
94         "--write-commands-to",
95         metavar="PATH",
96         dest="cmdpath",
97         help="Temporary file path to write commands to",
98     )
99
100     parser_massage.add_argument(
101         "--extensions",
102         metavar="EXTENSIONS",
103         type=str,
104         default="",
105         help="Markdown extension to use (comma-separated list)",
106     )
107
108     parser_massage.add_argument(
109         "--only-build",
110         action="store_true",
111         help="Only build, don't send the message",
112     )
113
114     parser_massage.add_argument(
115         "--tempdir",
116         default=None,
117         help="Specify temporary directory to use for attachments",
118     )
119
120     parser_massage.add_argument(
121         "--debug-commands",
122         action="store_true",
123         help="Turn on debug logging of commands generated to stderr",
124     )
125
126     parser_massage.add_argument(
127         "--debug-walk",
128         action="store_true",
129         help="Turn on debugging to stderr of the MIME tree walk",
130     )
131
132     parser_massage.add_argument(
133         "MAILDRAFT",
134         nargs="?",
135         help="If provided, the script is invoked as editor on the mail draft",
136     )
137
138     return parser.parse_args(*args, **kwargs)
139
140
141 # [ MARKDOWN WRAPPING ] #######################################################
142
143
144 InlineImageInfo = namedtuple(
145     "InlineImageInfo", ["cid", "desc"], defaults=[None]
146 )
147
148
149 class InlineImageExtension(Extension):
150     class RelatedImageInlineProcessor(ImageInlineProcessor):
151         def __init__(self, re, md, ext):
152             super().__init__(re, md)
153             self._ext = ext
154
155         def handleMatch(self, m, data):
156             el, start, end = super().handleMatch(m, data)
157             if "src" in el.attrib:
158                 src = el.attrib["src"]
159                 if "://" not in src or src.startswith("file://"):
160                     # We only inline local content
161                     cid = self._ext.get_cid_for_image(el.attrib)
162                     el.attrib["src"] = f"cid:{cid}"
163             return el, start, end
164
165     def __init__(self):
166         super().__init__()
167         self._images = OrderedDict()
168
169     def extendMarkdown(self, md):
170         md.registerExtension(self)
171         inline_image_proc = self.RelatedImageInlineProcessor(
172             IMAGE_LINK_RE, md, self
173         )
174         md.inlinePatterns.register(inline_image_proc, "image_link", 150)
175
176     def get_cid_for_image(self, attrib):
177         msgid = make_msgid()[1:-1]
178         path = attrib["src"]
179         if path.startswith("/"):
180             path = f"file://{path}"
181         self._images[path] = InlineImageInfo(
182             msgid, attrib.get("title", attrib.get("alt"))
183         )
184         return msgid
185
186     def get_images(self):
187         return self._images
188
189
190 def markdown_with_inline_image_support(text, *, extensions=None):
191     inline_image_handler = InlineImageExtension()
192     extensions = extensions or []
193     extensions.append(inline_image_handler)
194     mdwn = markdown.Markdown(extensions=extensions)
195     htmltext = mdwn.convert(text)
196
197     images = inline_image_handler.get_images()
198
199     def replace_image_with_cid(matchobj):
200         for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
201             if m in images:
202                 return f"(cid:{images[m].cid}"
203         return matchobj.group(0)
204
205     text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
206     return text, htmltext, images
207
208
209 # [ PARTS GENERATION ] ########################################################
210
211
212 class Part(
213     namedtuple(
214         "Part",
215         ["type", "subtype", "path", "desc", "cid", "orig"],
216         defaults=[None, None, False],
217     )
218 ):
219     def __str__(self):
220         ret = f"<{self.type}/{self.subtype}>"
221         if self.cid:
222             ret = f"{ret} cid:{self.cid}"
223         if self.orig:
224             ret = f"{ret} ORIGINAL"
225         return ret
226
227
228 class Multipart(
229     namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
230 ):
231     def __str__(self):
232         return f"<multipart/{self.subtype}> children={len(self.children)}"
233
234
235 def filewriter_fn(path, content, mode="w", **kwargs):
236     with open(path, mode, **kwargs) as out_f:
237         out_f.write(content)
238
239
240 def collect_inline_images(
241     images, *, tempdir=None, filewriter_fn=filewriter_fn
242 ):
243     relparts = []
244     for path, info in images.items():
245         data = request.urlopen(path)
246
247         mimetype = data.headers["Content-Type"]
248         ext = mimetypes.guess_extension(mimetype)
249         tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
250         path = pathlib.Path(tempfilename[1])
251
252         filewriter_fn(path, data.read(), "w+b")
253
254         relparts.append(
255             Part(*mimetype.split("/"), path, cid=info.cid, desc=f"Image: {info.desc}")
256         )
257
258     return relparts
259
260
261 def convert_markdown_to_html(
262     origtext,
263     draftpath,
264     *,
265     filewriter_fn=filewriter_fn,
266     tempdir=None,
267     extensions=None,
268 ):
269     origtext, htmltext, images = markdown_with_inline_image_support(
270         origtext, extensions=extensions
271     )
272
273     filewriter_fn(draftpath, origtext, encoding="utf-8")
274     textpart = Part(
275         "text", "plain", draftpath, "Plain-text version", orig=True
276     )
277
278     htmlpath = draftpath.with_suffix(".html")
279     filewriter_fn(
280         htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
281     )
282     htmlpart = Part("text", "html", htmlpath, "HTML version")
283
284     altpart = Multipart(
285         "alternative", [textpart, htmlpart], "Group of alternative content"
286     )
287
288     imgparts = collect_inline_images(
289         images, tempdir=tempdir, filewriter_fn=filewriter_fn
290     )
291     if imgparts:
292         return Multipart(
293             "relative", [altpart] + imgparts, "Group of related content"
294         )
295     else:
296         return altpart
297
298
299 class MIMETreeDFWalker:
300     def __init__(self, *, visitor_fn=None, debug=False):
301         self._visitor_fn = visitor_fn
302         self._debug = debug
303
304     def walk(self, root, *, visitor_fn=None):
305         """
306         Recursive function to implement a depth-dirst walk of the MIME-tree
307         rooted at `root`.
308         """
309
310         if isinstance(root, list):
311             root = Multipart("mixed", children=root)
312
313         self._walk(
314             root,
315             stack=[],
316             visitor_fn=visitor_fn or self._visitor_fn,
317         )
318
319     def _walk(self, node, *, stack, visitor_fn):
320         # Let's start by enumerating the parts at the current level. At the
321         # root level, stack will be the empty list, and we expect a multipart/*
322         # container at this level. Later, e.g. within a mutlipart/alternative
323         # container, the subtree will just be the alternative parts, while the
324         # top of the stack will be the multipart/alternative container, which
325         # we will process after the following loop.
326
327         lead = f"{'| '*len(stack)}|-"
328         if isinstance(node, Multipart):
329             self.debugprint(
330                 f"{lead}{node} parents={[s.subtype for s in stack]}"
331             )
332
333             # Depth-first, so push the current container onto the stack,
334             # then descend …
335             stack.append(node)
336             self.debugprint("| " * (len(stack) + 1))
337             for child in node.children:
338                 self._walk(
339                     child,
340                     stack=stack,
341                     visitor_fn=visitor_fn,
342                 )
343             self.debugprint("| " * len(stack))
344             assert stack.pop() == node
345
346         else:
347             self.debugprint(f"{lead}{node}")
348
349         if visitor_fn:
350             visitor_fn(node, stack, debugprint=self.debugprint)
351
352     def debugprint(self, s, **kwargs):
353         if self._debug:
354             print(s, file=sys.stderr, **kwargs)
355
356
357 # [ RUN MODES ] ###############################################################
358
359
360 class MuttCommands:
361     """
362     Stupid class to interface writing out Mutt commands. This is quite a hack
363     to deal with the fact that Mutt runs "push" commands in reverse order, so
364     all of a sudden, things become very complicated when mixing with "real"
365     commands.
366
367     Hence we keep two sets of commands, and one set of pushes. Commands are
368     added to the first until a push is added, after which commands are added to
369     the second set of commands.
370
371     On flush(), the first set is printed, followed by the pushes in reverse,
372     and then the second set is printed. All 3 sets are then cleared.
373     """
374
375     def __init__(self, out_f=sys.stdout, *, debug=False):
376         self._cmd1, self._push, self._cmd2 = [], [], []
377         self._out_f = out_f
378         self._debug = debug
379
380     def cmd(self, s):
381         self.debugprint(s)
382         if self._push:
383             self._cmd2.append(s)
384         else:
385             self._cmd1.append(s)
386
387     def push(self, s):
388         s = s.replace('"', '"')
389         s = f'push "{s}"'
390         self.debugprint(s)
391         self._push.insert(0, s)
392
393     def flush(self):
394         print(
395             "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
396         )
397         self._cmd1, self._push, self._cmd2 = [], [], []
398
399     def debugprint(self, s, **kwargs):
400         if self._debug:
401             print(s, file=sys.stderr, **kwargs)
402
403
404 def do_setup(
405     extensions=None,
406     *,
407     out_f=sys.stdout,
408     only_build=False,
409     temppath=None,
410     tempdir=None,
411     debug_commands=False,
412 ):
413     extensions = extensions or []
414     temppath = temppath or pathlib.Path(
415         tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
416     )
417     cmds = MuttCommands(out_f, debug=debug_commands)
418
419     editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
420     if extensions:
421         editor = f'{editor} --extensions {",".join(extensions)}'
422     if only_build:
423         editor = f'{editor} --only-build'
424     if tempdir:
425         editor = f"{editor} --tempdir {tempdir}"
426     if debug_commands:
427         editor = f"{editor} --debug-commands"
428
429     cmds.cmd('set my_editor="$editor"')
430     cmds.cmd('set my_edit_headers="$edit_headers"')
431     cmds.cmd(f'set editor="{editor}"')
432     cmds.cmd("unset edit_headers")
433     cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
434     cmds.push("<first-entry><edit-file>")
435     cmds.flush()
436
437
438 def do_massage(
439     draft_f,
440     draftpath,
441     cmd_f,
442     *,
443     extensions=None,
444     converter=convert_markdown_to_html,
445     only_build=False,
446     tempdir=None,
447     debug_commands=False,
448     debug_walk=False,
449 ):
450     # Here's the big picture: we're being invoked as the editor on the email
451     # draft, and whatever commands we write to the file given as cmdpath will
452     # be run by the second source command in the macro definition.
453
454     # Let's start by cleaning up what the setup did (see above), i.e. we
455     # restore the $editor and $edit_headers variables, and also unset the
456     # variable used to identify the command file we're currently writing
457     # to.
458     cmds = MuttCommands(cmd_f, debug=debug_commands)
459     cmds.cmd('set editor="$my_editor"')
460     cmds.cmd('set edit_headers="$my_edit_headers"')
461     cmds.cmd("unset my_editor")
462     cmds.cmd("unset my_edit_headers")
463
464     # let's flush those commands, as there'll be a lot of pushes from now
465     # on, which need to be run in reverse order
466     cmds.flush()
467
468     extensions = extensions.split(",") if extensions else []
469     tree = converter(draft_f.read(), draftpath, tempdir=tempdir, extensions=extensions)
470
471     mimetree = MIMETreeDFWalker(debug=debug_walk)
472
473     def visitor_fn(item, stack, *, debugprint=None):
474         """
475         Visitor function called for every node (part) of the MIME tree,
476         depth-first, and responsible for telling NeoMutt how to assemble
477         the tree.
478         """
479         if isinstance(item, Part):
480             # We've hit a leaf-node, i.e. an alternative or a related part
481             # with actual content.
482
483             # Let's add the part
484             if item.orig:
485                 # The original source already exists in the NeoMutt tree, but
486                 # the underlying file may have been modified, so we need to
487                 # update the encoding, but that's it:
488                 cmds.push("<update-encoding>")
489             else:
490                 # … whereas all other parts need to be added, and they're all
491                 # considered to be temporary and inline:
492                 cmds.push(f"<attach-file>{item.path}<enter>")
493                 cmds.push("<toggle-unlink><toggle-disposition>")
494
495             # If the item (including the original) comes with additional
496             # information, then we might just as well update the NeoMutt
497             # tree now:
498             if item.cid:
499                 cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
500
501         elif isinstance(item, Multipart):
502             # This node has children, but we already visited them (see
503             # above), and so they have been tagged in NeoMutt's compose
504             # window. Now it's just a matter of telling NeoMutt to do the
505             # appropriate grouping:
506             if item.subtype == "alternative":
507                 cmds.push("<group-alternatives>")
508             elif item.subtype in ("relative", "related"):
509                 cmds.push("<group-related>")
510             elif item.subtype == "multilingual":
511                 cmds.push("<group-multilingual>")
512
513         else:
514             # We should never get here
515             assert not "is valid part"
516
517         # If the item has a description, we might just as well add it
518         if item.desc:
519             cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
520
521         # Finally, if we're at non-root level, tag the new container,
522         # as it might itself be part of a container, to be processed
523         # one level up:
524         if stack:
525             cmds.push("<tag-entry>")
526
527     # -----------------
528     # End of visitor_fn
529
530     # Let's walk the tree and visit every node with our fancy visitor
531     # function
532     mimetree.walk(tree, visitor_fn=visitor_fn)
533
534     if not only_build:
535         cmds.push("<send-message>")
536
537     # Finally, cleanup. Since we're responsible for removing the temporary
538     # file, how's this for a little hack?
539     try:
540         filename = cmd_f.name
541     except AttributeError:
542         filename = "pytest_internal_file"
543     cmds.cmd(f"source 'rm -f {filename}|'")
544     cmds.cmd("unset my_mdwn_postprocess_cmd_file")
545     cmds.flush()
546
547
548 # [ CLI ENTRY ] ###############################################################
549
550 if __name__ == "__main__":
551     args = parse_cli_args()
552
553     if args.mode == "setup":
554         do_setup(
555             args.extensions,
556             only_build=args.only_build,
557             tempdir=args.tempdir,
558             debug_commands=args.debug_commands,
559         )
560
561     elif args.mode == "massage":
562         with open(args.MAILDRAFT, "r") as draft_f, open(
563             args.cmdpath, "w"
564         ) as cmd_f:
565             do_massage(
566                 draft_f,
567                 pathlib.Path(args.MAILDRAFT),
568                 cmd_f,
569                 extensions=args.extensions,
570                 only_build=args.only_build,
571                 tempdir=args.tempdir,
572                 debug_commands=args.debug_commands,
573                 debug_walk=args.debug_walk,
574             )
575
576
577 # [ TESTS ] ###################################################################
578
579 try:
580     import pytest
581     from io import StringIO
582
583     class Tests:
584         @pytest.fixture
585         def const1(self):
586             return "CONSTANT STRING 1"
587
588         @pytest.fixture
589         def const2(self):
590             return "CONSTANT STRING 2"
591
592         # NOTE: tests using the capsys fixture must specify sys.stdout to the
593         # functions they call, else old stdout is used and not captured
594
595         def test_MuttCommands_cmd(self, const1, const2, capsys):
596             "Assert order of commands"
597             cmds = MuttCommands(out_f=sys.stdout)
598             cmds.cmd(const1)
599             cmds.cmd(const2)
600             cmds.flush()
601             captured = capsys.readouterr()
602             assert captured.out == "\n".join((const1, const2, ""))
603
604         def test_MuttCommands_push(self, const1, const2, capsys):
605             "Assert reverse order of pushes"
606             cmds = MuttCommands(out_f=sys.stdout)
607             cmds.push(const1)
608             cmds.push(const2)
609             cmds.flush()
610             captured = capsys.readouterr()
611             assert (
612                 captured.out
613                 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
614             )
615
616         def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
617             "Assert reverse order of pushes"
618             cmds = MuttCommands(out_f=sys.stdout)
619             lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
620             for i in range(2):
621                 cmds.cmd(lines[4 * i + 0])
622                 cmds.cmd(lines[4 * i + 1])
623                 cmds.push(lines[4 * i + 2])
624                 cmds.push(lines[4 * i + 3])
625             cmds.flush()
626
627             captured = capsys.readouterr()
628             lines_out = captured.out.splitlines()
629             assert lines[0] in lines_out[0]
630             assert lines[1] in lines_out[1]
631             assert lines[7] in lines_out[2]
632             assert lines[6] in lines_out[3]
633             assert lines[3] in lines_out[4]
634             assert lines[2] in lines_out[5]
635             assert lines[4] in lines_out[6]
636             assert lines[5] in lines_out[7]
637
638         @pytest.fixture
639         def basic_mime_tree(self):
640             return Multipart(
641                 "relative",
642                 children=[
643                     Multipart(
644                         "alternative",
645                         children=[
646                             Part(
647                                 "text",
648                                 "plain",
649                                 "part.txt",
650                                 desc="Plain",
651                                 orig=True,
652                             ),
653                             Part("text", "html", "part.html", desc="HTML"),
654                         ],
655                         desc="Alternative",
656                     ),
657                     Part(
658                         "text", "png", "logo.png", cid="logo.png", desc="Logo"
659                     ),
660                 ],
661                 desc="Related",
662             )
663
664         def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
665             mimetree = MIMETreeDFWalker()
666
667             items = []
668
669             def visitor_fn(item, stack, debugprint):
670                 items.append((item, len(stack)))
671
672             mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
673             assert len(items) == 5
674             assert items[0][0].subtype == "plain"
675             assert items[0][1] == 2
676             assert items[1][0].subtype == "html"
677             assert items[1][1] == 2
678             assert items[2][0].subtype == "alternative"
679             assert items[2][1] == 1
680             assert items[3][0].subtype == "png"
681             assert items[3][1] == 1
682             assert items[4][0].subtype == "relative"
683             assert items[4][1] == 0
684
685         def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
686             mimetree = MIMETreeDFWalker()
687             items = []
688
689             def visitor_fn(item, stack, debugprint):
690                 items.append(item)
691
692             mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
693             assert items[-1].subtype == "mixed"
694
695         def test_MIMETreeDFWalker_visitor_in_constructor(
696             self, basic_mime_tree
697         ):
698             items = []
699
700             def visitor_fn(item, stack, debugprint):
701                 items.append(item)
702
703             mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
704             mimetree.walk(basic_mime_tree)
705             assert len(items) == 5
706
707         def test_do_setup_no_extensions(self, const1, capsys):
708             "Assert basics about the setup command output"
709             do_setup(temppath=const1, out_f=sys.stdout)
710             captout = capsys.readouterr()
711             lines = captout.out.splitlines()
712             assert lines[2].endswith(f'{const1}"')
713             assert lines[4].endswith(const1)
714             assert "first-entry" in lines[-1]
715             assert "edit-file" in lines[-1]
716
717         def test_do_setup_extensions(self, const1, const2, capsys):
718             "Assert that extensions are passed to editor"
719             do_setup(
720                 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
721             )
722             captout = capsys.readouterr()
723             lines = captout.out.splitlines()
724             # assert comma-separated list of extensions passed
725             assert lines[2].endswith(f'{const2},{const1}"')
726             assert lines[4].endswith(const1)
727
728         @pytest.fixture
729         def string_io(self, const1, text=None):
730             return StringIO(text or const1)
731
732         def test_do_massage_basic(self, const1, string_io, capsys):
733             def converter(drafttext, draftpath, extensions, tempdir):
734                 return Part("text", "plain", draftpath, orig=True)
735
736             do_massage(
737                 draft_f=string_io,
738                 draftpath=const1,
739                 cmd_f=sys.stdout,
740                 converter=converter,
741             )
742
743             captured = capsys.readouterr()
744             lines = captured.out.splitlines()
745             assert '="$my_editor"' in lines.pop(0)
746             assert '="$my_edit_headers"' in lines.pop(0)
747             assert "unset my_editor" == lines.pop(0)
748             assert "unset my_edit_headers" == lines.pop(0)
749             assert "send-message" in lines.pop(0)
750             assert "update-encoding" in lines.pop(0)
751             assert "source 'rm -f " in lines.pop(0)
752             assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
753
754         def test_do_massage_fulltree(
755             self, string_io, const1, basic_mime_tree, capsys
756         ):
757             def converter(drafttext, draftpath, extensions, tempdir):
758                 return basic_mime_tree
759
760             do_massage(
761                 draft_f=string_io,
762                 draftpath=const1,
763                 cmd_f=sys.stdout,
764                 converter=converter,
765             )
766
767             captured = capsys.readouterr()
768             lines = captured.out.splitlines()[4:]
769             assert "send-message" in lines.pop(0)
770             assert "Related" in lines.pop(0)
771             assert "group-related" in lines.pop(0)
772             assert "tag-entry" in lines.pop(0)
773             assert "Logo" in lines.pop(0)
774             assert "content-id" in lines.pop(0)
775             assert "toggle-unlink" in lines.pop(0)
776             assert "logo.png" in lines.pop(0)
777             assert "tag-entry" in lines.pop(0)
778             assert "Alternative" in lines.pop(0)
779             assert "group-alternatives" in lines.pop(0)
780             assert "tag-entry" in lines.pop(0)
781             assert "HTML" in lines.pop(0)
782             assert "toggle-unlink" in lines.pop(0)
783             assert "part.html" in lines.pop(0)
784             assert "tag-entry" in lines.pop(0)
785             assert "Plain" in lines.pop(0)
786             assert "update-encoding" in lines.pop(0)
787             assert len(lines) == 2
788
789         @pytest.fixture
790         def fake_filewriter(self):
791             class FileWriter:
792                 def __init__(self):
793                     self._writes = []
794
795                 def __call__(self, path, content, mode="w", **kwargs):
796                     self._writes.append((path, content))
797
798                 def pop(self, index=-1):
799                     return self._writes.pop(index)
800
801             return FileWriter()
802
803         @pytest.fixture
804         def markdown_non_converter(self, const1, const2):
805             return lambda s, text: f"{const1}{text}{const2}"
806
807         def test_converter_tree_basic(
808             self, const1, const2, fake_filewriter, markdown_non_converter
809         ):
810             path = pathlib.Path(const2)
811             tree = convert_markdown_to_html(
812                 const1, path, filewriter_fn=fake_filewriter
813             )
814
815             assert tree.subtype == "alternative"
816             assert len(tree.children) == 2
817             assert tree.children[0].subtype == "plain"
818             assert tree.children[0].path == path
819             assert tree.children[0].orig
820             assert tree.children[1].subtype == "html"
821             assert tree.children[1].path == path.with_suffix(".html")
822
823         def test_converter_writes(
824             self,
825             const1,
826             const2,
827             fake_filewriter,
828             monkeypatch,
829             markdown_non_converter,
830         ):
831             path = pathlib.Path(const2)
832
833             with monkeypatch.context() as m:
834                 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
835                 convert_markdown_to_html(
836                     const1, path, filewriter_fn=fake_filewriter
837                 )
838
839             assert (path, const1) == fake_filewriter.pop(0)
840             assert (
841                 path.with_suffix(".html"),
842                 markdown_non_converter(None, const1),
843             ) == fake_filewriter.pop(0)
844
845         def test_markdown_inline_image_processor(self):
846             imgpath1 = "file:/path/to/image.png"
847             imgpath2 = "file:///path/to/image.png?url=params"
848             imgpath3 = "/path/to/image.png"
849             text = f"""![inline local image]({imgpath1})
850                        ![image inlined
851                          with newline]({imgpath2})
852                        ![image local path]({imgpath3})"""
853             text, html, images = markdown_with_inline_image_support(text)
854
855             # local paths have been normalised to URLs:
856             imgpath3 = f"file://{imgpath3}"
857
858             assert 'src="cid:' in html
859             assert "](cid:" in text
860             assert len(images) == 3
861             assert imgpath1 in images
862             assert imgpath2 in images
863             assert imgpath3 in images
864             assert images[imgpath1].cid != images[imgpath2].cid
865             assert images[imgpath1].cid != images[imgpath3].cid
866             assert images[imgpath2].cid != images[imgpath3].cid
867
868         def test_markdown_inline_image_processor_title_to_desc(self, const1):
869             imgpath = "file:///path/to/image.png"
870             text = f'![inline local image]({imgpath} "{const1}")'
871             text, html, images = markdown_with_inline_image_support(text)
872             assert images[imgpath].desc == const1
873
874         def test_markdown_inline_image_processor_alt_to_desc(self, const1):
875             imgpath = "file:///path/to/image.png"
876             text = f"![{const1}]({imgpath})"
877             text, html, images = markdown_with_inline_image_support(text)
878             assert images[imgpath].desc == const1
879
880         def test_markdown_inline_image_processor_title_over_alt_desc(
881             self, const1, const2
882         ):
883             imgpath = "file:///path/to/image.png"
884             text = f'![{const1}]({imgpath} "{const2}")'
885             text, html, images = markdown_with_inline_image_support(text)
886             assert images[imgpath].desc == const2
887
888         def test_markdown_inline_image_not_external(self):
889             imgpath = "https://path/to/image.png"
890             text = f"![inline image]({imgpath})"
891             text, html, images = markdown_with_inline_image_support(text)
892
893             assert 'src="cid:' not in html
894             assert "](cid:" not in text
895             assert len(images) == 0
896
897         def test_markdown_inline_image_local_file(self):
898             imgpath = "/path/to/image.png"
899             text = f"![inline image]({imgpath})"
900             text, html, images = markdown_with_inline_image_support(text)
901
902             for k, v in images.items():
903                 assert k == f"file://{imgpath}"
904                 break
905
906         @pytest.fixture
907         def test_png(self):
908             return (
909                 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
910                 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
911             )
912
913         def test_markdown_inline_image_processor_base64(self, test_png):
914             text = f"![1px white inlined]({test_png})"
915             text, html, images = markdown_with_inline_image_support(text)
916
917             assert 'src="cid:' in html
918             assert "](cid:" in text
919             assert len(images) == 1
920             assert test_png in images
921
922         def test_converter_tree_inline_image_base64(
923             self, test_png, const1, fake_filewriter
924         ):
925             text = f"![inline base64 image]({test_png})"
926             path = pathlib.Path(const1)
927             tree = convert_markdown_to_html(
928                 text, path, filewriter_fn=fake_filewriter
929             )
930
931             assert tree.subtype == "relative"
932             assert tree.children[1].subtype == "png"
933             written = fake_filewriter.pop()
934             assert tree.children[1].path == written[0]
935             assert written[1] == request.urlopen(test_png).read()
936
937         def test_inline_image_collection(
938             self, test_png, const1, const2, fake_filewriter
939         ):
940             test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
941             relparts = collect_inline_images(
942                 test_images, filewriter_fn=fake_filewriter
943             )
944
945             written = fake_filewriter.pop()
946             assert b"PNG" in written[1]
947
948             assert relparts[0].subtype == "png"
949             assert relparts[0].path == written[0]
950             assert relparts[0].cid == const1
951             assert relparts[0].desc.endswith(const2)
952
953 except ImportError:
954     pass