]> 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:

buildmimetree.py: externalise file i/o from converter
[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|'<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 from collections import namedtuple
40
41
42 def parse_cli_args(*args, **kwargs):
43     parser = argparse.ArgumentParser(
44         description=(
45             "NeoMutt helper to turn text/markdown email parts "
46             "into full-fledged MIME trees"
47         )
48     )
49     parser.epilog = (
50         "Copyright © 2022 martin f. krafft <madduck@madduck.net>.\n"
51         "Released under the MIT licence"
52     )
53
54     subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
55     parser_setup = subp.add_parser("setup", help="Setup phase")
56     parser_massage = subp.add_parser("massage", help="Massaging phase")
57
58     parser_setup.add_argument(
59         "--debug-commands",
60         action="store_true",
61         help="Turn on debug logging of commands generated to stderr",
62     )
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         "--send-message",
77         action="store_true",
78         help="Generate command(s) to send the message after processing",
79     )
80
81     parser_massage.add_argument(
82         "--debug-commands",
83         action="store_true",
84         help="Turn on debug logging of commands generated to stderr",
85     )
86
87     parser_massage.add_argument(
88         "--debug-walk",
89         action="store_true",
90         help="Turn on debugging to stderr of the MIME tree walk",
91     )
92
93     parser_massage.add_argument(
94         "--extensions",
95         metavar="EXTENSIONS",
96         type=str,
97         default="",
98         help="Markdown extension to use (comma-separated list)",
99     )
100
101     parser_massage.add_argument(
102         "--write-commands-to",
103         metavar="PATH",
104         dest="cmdpath",
105         help="Temporary file path to write commands to",
106     )
107
108     parser_massage.add_argument(
109         "MAILDRAFT",
110         nargs="?",
111         help="If provided, the script is invoked as editor on the mail draft",
112     )
113
114     return parser.parse_args(*args, **kwargs)
115
116
117 # [ PARTS GENERATION ] ########################################################
118
119
120 class Part(
121     namedtuple(
122         "Part",
123         ["type", "subtype", "path", "desc", "cid", "orig"],
124         defaults=[None, None, False],
125     )
126 ):
127     def __str__(self):
128         ret = f"<{self.type}/{self.subtype}>"
129         if self.cid:
130             ret = f"{ret} cid:{self.cid}"
131         if self.orig:
132             ret = f"{ret} ORIGINAL"
133         return ret
134
135
136 class Multipart(
137     namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
138 ):
139     def __str__(self):
140         return f"<multipart/{self.subtype}> children={len(self.children)}"
141
142
143
144 def convert_markdown_to_html(
145     origtext, draftpath, *, filewriter_fn=None, extensions=None
146 ):
147     mdwn = markdown.Markdown(extensions=extensions)
148
149     if not filewriter_fn:
150
151         def filewriter_fn(path, content, mode="w", **kwargs):
152             with open(path, mode, **kwargs) as out_f:
153                 out_f.write(content)
154
155     filewriter_fn(draftpath, origtext, encoding="utf-8")
156     textpart = Part(
157         "text", "plain", draftpath, "Plain-text version", orig=True
158     )
159
160     htmltext = mdwn.convert(origtext)
161
162     htmlpath = draftpath.with_suffix(".html")
163     filewriter_fn(
164         htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
165     )
166     htmlpart = Part("text", "html", htmlpath, "HTML version")
167
168     logopart = Part(
169         "image",
170         "png",
171         "/usr/share/doc/neomutt/logo/neomutt-256.png",
172         "Logo",
173         "neomutt-256.png",
174     )
175
176     return Multipart(
177         "relative",
178         [
179             Multipart(
180                 "alternative",
181                 [textpart, htmlpart],
182                 "Group of alternative content",
183             ),
184             logopart,
185         ],
186         "Group of related content",
187     )
188
189
190 class MIMETreeDFWalker:
191     def __init__(self, *, visitor_fn=None, debug=False):
192         self._visitor_fn = visitor_fn
193         self._debug = debug
194
195     def walk(self, root, *, visitor_fn=None):
196         """
197         Recursive function to implement a depth-dirst walk of the MIME-tree
198         rooted at `root`.
199         """
200
201         if isinstance(root, list):
202             root = Multipart("mixed", children=root)
203
204         self._walk(
205             root,
206             stack=[],
207             visitor_fn=visitor_fn or self._visitor_fn,
208         )
209
210     def _walk(self, node, *, stack, visitor_fn):
211         # Let's start by enumerating the parts at the current level. At the
212         # root level, stack will be the empty list, and we expect a multipart/*
213         # container at this level. Later, e.g. within a mutlipart/alternative
214         # container, the subtree will just be the alternative parts, while the
215         # top of the stack will be the multipart/alternative container, which
216         # we will process after the following loop.
217
218         lead = f"{'| '*len(stack)}|-"
219         if isinstance(node, Multipart):
220             self.debugprint(
221                 f"{lead}{node} parents={[s.subtype for s in stack]}"
222             )
223
224             # Depth-first, so push the current container onto the stack,
225             # then descend …
226             stack.append(node)
227             self.debugprint("| " * (len(stack) + 1))
228             for child in node.children:
229                 self._walk(
230                     child,
231                     stack=stack,
232                     visitor_fn=visitor_fn,
233                 )
234             self.debugprint("| " * len(stack))
235             assert stack.pop() == node
236
237         else:
238             self.debugprint(f"{lead}{node}")
239
240         if visitor_fn:
241             visitor_fn(node, stack, debugprint=self.debugprint)
242
243     def debugprint(self, s, **kwargs):
244         if self._debug:
245             print(s, file=sys.stderr, **kwargs)
246
247
248 # [ RUN MODES ] ###############################################################
249
250
251 class MuttCommands:
252     """
253     Stupid class to interface writing out Mutt commands. This is quite a hack
254     to deal with the fact that Mutt runs "push" commands in reverse order, so
255     all of a sudden, things become very complicated when mixing with "real"
256     commands.
257
258     Hence we keep two sets of commands, and one set of pushes. Commands are
259     added to the first until a push is added, after which commands are added to
260     the second set of commands.
261
262     On flush(), the first set is printed, followed by the pushes in reverse,
263     and then the second set is printed. All 3 sets are then cleared.
264     """
265
266     def __init__(self, out_f=sys.stdout, *, debug=False):
267         self._cmd1, self._push, self._cmd2 = [], [], []
268         self._out_f = out_f
269         self._debug = debug
270
271     def cmd(self, s):
272         self.debugprint(s)
273         if self._push:
274             self._cmd2.append(s)
275         else:
276             self._cmd1.append(s)
277
278     def push(self, s):
279         s = s.replace('"', '"')
280         s = f'push "{s}"'
281         self.debugprint(s)
282         self._push.insert(0, s)
283
284     def flush(self):
285         print(
286             "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
287         )
288         self._cmd1, self._push, self._cmd2 = [], [], []
289
290     def debugprint(self, s, **kwargs):
291         if self._debug:
292             print(s, file=sys.stderr, **kwargs)
293
294
295 def do_setup(
296     extensions=None, *, out_f=sys.stdout, temppath=None, debug_commands=False
297 ):
298     extensions = extensions or []
299     temppath = temppath or pathlib.Path(
300         tempfile.mkstemp(prefix="muttmdwn-")[1]
301     )
302     cmds = MuttCommands(out_f, debug=debug_commands)
303
304     editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
305     if extensions:
306         editor = f'{editor} --extensions {",".join(extensions)}'
307
308     cmds.cmd('set my_editor="$editor"')
309     cmds.cmd('set my_edit_headers="$edit_headers"')
310     cmds.cmd(f'set editor="{editor}"')
311     cmds.cmd("unset edit_headers")
312     cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
313     cmds.push("<first-entry><edit-file>")
314     cmds.flush()
315
316
317 def do_massage(
318     maildraft,
319     cmd_f,
320     *,
321     extensions=None,
322     converter=convert_markdown_to_html,
323     debug_commands=False,
324     debug_walk=False,
325 ):
326     # Here's the big picture: we're being invoked as the editor on the email
327     # draft, and whatever commands we write to the file given as cmdpath will
328     # be run by the second source command in the macro definition.
329
330     # Let's start by cleaning up what the setup did (see above), i.e. we
331     # restore the $editor and $edit_headers variables, and also unset the
332     # variable used to identify the command file we're currently writing
333     # to.
334     cmds = MuttCommands(cmd_f, debug=debug_commands)
335     cmds.cmd('set editor="$my_editor"')
336     cmds.cmd('set edit_headers="$my_edit_headers"')
337     cmds.cmd("unset my_editor")
338     cmds.cmd("unset my_edit_headers")
339
340     # let's flush those commands, as there'll be a lot of pushes from now
341     # on, which need to be run in reverse order
342     cmds.flush()
343
344     extensions = extensions.split(",") if extensions else []
345     with open(maildraft, "r") as draft_f:
346         tree = converter(
347             draft_f.read(), pathlib.Path(maildraft), extensions=extensions
348         )
349
350     mimetree = MIMETreeDFWalker(debug=debug_walk)
351
352     def visitor_fn(item, stack, *, debugprint=None):
353         """
354         Visitor function called for every node (part) of the MIME tree,
355         depth-first, and responsible for telling NeoMutt how to assemble
356         the tree.
357         """
358         if isinstance(item, Part):
359             # We've hit a leaf-node, i.e. an alternative or a related part
360             # with actual content.
361
362             # Let's add the part
363             if item.orig:
364                 # The original source already exists in the NeoMutt tree, but
365                 # the underlying file may have been modified, so we need to
366                 # update the encoding, but that's it:
367                 cmds.push("<update-encoding>")
368             else:
369                 # … whereas all other parts need to be added, and they're all
370                 # considered to be temporary and inline:
371                 cmds.push(f"<attach-file>{item.path}<enter>")
372                 cmds.push("<toggle-unlink><toggle-disposition>")
373
374             # If the item (including the original) comes with additional
375             # information, then we might just as well update the NeoMutt
376             # tree now:
377             if item.cid:
378                 cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
379
380         elif isinstance(item, Multipart):
381             # This node has children, but we already visited them (see
382             # above), and so they have been tagged in NeoMutt's compose
383             # window. Now it's just a matter of telling NeoMutt to do the
384             # appropriate grouping:
385             if item.subtype == "alternative":
386                 cmds.push("<group-alternatives>")
387             elif item.subtype == "relative":
388                 cmds.push("<group-related>")
389             elif item.subtype == "multilingual":
390                 cmds.push("<group-multilingual>")
391
392         else:
393             # We should never get here
394             assert not "is valid part"
395
396         # If the item has a description, we might just as well add it
397         if item.desc:
398             cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
399
400         # Finally, if we're at non-root level, tag the new container,
401         # as it might itself be part of a container, to be processed
402         # one level up:
403         if stack:
404             cmds.push("<tag-entry>")
405
406     # -----------------
407     # End of visitor_fn
408
409     # Let's walk the tree and visit every node with our fancy visitor
410     # function
411     mimetree.walk(tree, visitor_fn=visitor_fn)
412
413     # Finally, cleanup. Since we're responsible for removing the temporary
414     # file, how's this for a little hack?
415     try:
416         filename = cmd_f.name
417     except AttributeError:
418         filename = "pytest_internal_file"
419     cmds.cmd(f"source 'rm -f {filename}|'")
420     cmds.cmd("unset my_mdwn_postprocess_cmd_file")
421     cmds.flush()
422
423
424 # [ CLI ENTRY ] ###############################################################
425
426 if __name__ == "__main__":
427     args = parse_cli_args()
428
429     if args.mode == "setup":
430         if args.send_message:
431             raise NotImplementedError()
432
433         do_setup(args.extensions, debug_commands=args.debug_commands)
434
435     elif args.mode == "massage":
436         with open(args.cmdpath, "w") as cmd_f:
437             do_massage(
438                 args.MAILDRAFT,
439                 cmd_f,
440                 extensions=args.extensions,
441                 debug_commands=args.debug_commands,
442                 debug_walk=args.debug_walk,
443             )
444
445
446 # [ TESTS ] ###################################################################
447
448 try:
449     import pytest
450
451     class Tests:
452         @pytest.fixture
453         def const1(self):
454             return "CONSTANT STRING 1"
455
456         @pytest.fixture
457         def const2(self):
458             return "CONSTANT STRING 2"
459
460         # NOTE: tests using the capsys fixture must specify sys.stdout to the
461         # functions they call, else old stdout is used and not captured
462
463         def test_MuttCommands_cmd(self, const1, const2, capsys):
464             "Assert order of commands"
465             cmds = MuttCommands(out_f=sys.stdout)
466             cmds.cmd(const1)
467             cmds.cmd(const2)
468             cmds.flush()
469             captured = capsys.readouterr()
470             assert captured.out == "\n".join((const1, const2, ""))
471
472         def test_MuttCommands_push(self, const1, const2, capsys):
473             "Assert reverse order of pushes"
474             cmds = MuttCommands(out_f=sys.stdout)
475             cmds.push(const1)
476             cmds.push(const2)
477             cmds.flush()
478             captured = capsys.readouterr()
479             assert (
480                 captured.out
481                 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
482             )
483
484         def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
485             "Assert reverse order of pushes"
486             cmds = MuttCommands(out_f=sys.stdout)
487             lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
488             for i in range(2):
489                 cmds.cmd(lines[4 * i + 0])
490                 cmds.cmd(lines[4 * i + 1])
491                 cmds.push(lines[4 * i + 2])
492                 cmds.push(lines[4 * i + 3])
493             cmds.flush()
494
495             captured = capsys.readouterr()
496             lines_out = captured.out.splitlines()
497             assert lines[0] in lines_out[0]
498             assert lines[1] in lines_out[1]
499             assert lines[7] in lines_out[2]
500             assert lines[6] in lines_out[3]
501             assert lines[3] in lines_out[4]
502             assert lines[2] in lines_out[5]
503             assert lines[4] in lines_out[6]
504             assert lines[5] in lines_out[7]
505
506         @pytest.fixture
507         def basic_mime_tree(self):
508             return Multipart(
509                 "relative",
510                 children=[
511                     Multipart(
512                         "alternative",
513                         children=[
514                             Part(
515                                 "text",
516                                 "plain",
517                                 "part.txt",
518                                 desc="Plain",
519                                 orig=True,
520                             ),
521                             Part("text", "html", "part.html", desc="HTML"),
522                         ],
523                         desc="Alternative",
524                     ),
525                     Part(
526                         "text", "png", "logo.png", cid="logo.png", desc="Logo"
527                     ),
528                 ],
529                 desc="Related",
530             )
531
532         def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
533             mimetree = MIMETreeDFWalker()
534
535             items = []
536
537             def visitor_fn(item, stack, debugprint):
538                 items.append((item, len(stack)))
539
540             mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
541             assert len(items) == 5
542             assert items[0][0].subtype == "plain"
543             assert items[0][1] == 2
544             assert items[1][0].subtype == "html"
545             assert items[1][1] == 2
546             assert items[2][0].subtype == "alternative"
547             assert items[2][1] == 1
548             assert items[3][0].subtype == "png"
549             assert items[3][1] == 1
550             assert items[4][0].subtype == "relative"
551             assert items[4][1] == 0
552
553         def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
554             mimetree = MIMETreeDFWalker()
555             items = []
556
557             def visitor_fn(item, stack, debugprint):
558                 items.append(item)
559
560             mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
561             assert items[-1].subtype == "mixed"
562
563         def test_MIMETreeDFWalker_visitor_in_constructor(
564             self, basic_mime_tree
565         ):
566             items = []
567
568             def visitor_fn(item, stack, debugprint):
569                 items.append(item)
570
571             mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
572             mimetree.walk(basic_mime_tree)
573             assert len(items) == 5
574
575         def test_do_setup_no_extensions(self, const1, capsys):
576             "Assert basics about the setup command output"
577             do_setup(temppath=const1, out_f=sys.stdout)
578             captout = capsys.readouterr()
579             lines = captout.out.splitlines()
580             assert lines[2].endswith(f'{const1}"')
581             assert lines[4].endswith(const1)
582             assert "first-entry" in lines[-1]
583             assert "edit-file" in lines[-1]
584
585         def test_do_setup_extensions(self, const1, const2, capsys):
586             "Assert that extensions are passed to editor"
587             do_setup(
588                 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
589             )
590             captout = capsys.readouterr()
591             lines = captout.out.splitlines()
592             # assert comma-separated list of extensions passed
593             assert lines[2].endswith(f'{const2},{const1}"')
594             assert lines[4].endswith(const1)
595
596         def test_do_massage_basic(self, const1, capsys):
597             def converter(maildraft, extensions):
598                 return Part("text", "plain", "/dev/null", orig=True)
599
600             do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
601             captured = capsys.readouterr()
602             assert (
603                 captured.out.strip()
604                 == """\
605             set editor="$my_editor"
606             set edit_headers="$my_edit_headers"
607             unset my_editor
608             unset my_edit_headers
609             source 'rm -f pytest_internal_file|'
610             unset my_mdwn_postprocess_cmd_file
611             """.replace(
612                     "            ", ""
613                 ).strip()
614             )
615
616         def test_do_massage_fulltree(self, const1, basic_mime_tree, capsys):
617             def converter(maildraft, extensions):
618                 return basic_mime_tree
619
620             do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
621             captured = capsys.readouterr()
622             lines = captured.out.splitlines()[4:][::-1]
623             assert "Related" in lines.pop()
624             assert "group-related" in lines.pop()
625             assert "tag-entry" in lines.pop()
626             assert "Logo" in lines.pop()
627             assert "content-id" in lines.pop()
628             assert "toggle-unlink" in lines.pop()
629             assert "logo.png" in lines.pop()
630             assert "tag-entry" in lines.pop()
631             assert "Alternative" in lines.pop()
632             assert "group-alternatives" in lines.pop()
633             assert "tag-entry" in lines.pop()
634             assert "HTML" in lines.pop()
635             assert "toggle-unlink" in lines.pop()
636             assert "part.html" in lines.pop()
637             assert "tag-entry" in lines.pop()
638             assert "Plain" in lines.pop()
639
640 except ImportError:
641     pass