changeset 8491:520075b29474

feat: support justhtml parsing library to convert email to plain text justhtml is an pure python, fast, HTML5 compliant parser. It is now an option for converting html only emails to plain text. Its output format differs slightly from dehtml or beautifulsoup. Mostly by removing extra blank lines. dehtml.py: Using the stream parser of justhtml. Unable to get the full document parser to successfully strip script and style blocks. If I can fix this and use the standard parser, I can in theory generate markdown from the DOM tree generated by justhtml. Updated test case to include inline elements that should not cause a line break when they are encountered. Running dehtml as: `python roundup/dehtml.py foo.html` will load foo.html and parse it using all available parsers. configuration.py: justhtml is available as an option. docs: updated CHANGES.txt, doc/tracker_config.txt added beautifulsoup and justhtml to the optional software section of doc/installtion.txt. test_mailgw.py, .github/workflows/ci-test Updated tests and install justhtml as part of CI.
author John Rouillard <rouilj@ieee.org>
date Sun, 14 Dec 2025 22:40:46 -0500
parents 918792e35e0c
children 166cb2632315
files .github/workflows/ci-test.yml CHANGES.txt doc/installation.txt doc/tracker_config.txt roundup/configuration.py roundup/dehtml.py test/test_mailgw.py
diffstat 7 files changed, 191 insertions(+), 21 deletions(-) [+]
line wrap: on
line diff
--- a/.github/workflows/ci-test.yml	Sat Dec 13 23:02:53 2025 -0500
+++ b/.github/workflows/ci-test.yml	Sun Dec 14 22:40:46 2025 -0500
@@ -240,7 +240,7 @@
           # pygments for markdown2 to highlight code blocks
           pip install markdown2 pygments
           # docutils for ReStructuredText
-          pip install beautifulsoup4 brotli docutils jinja2 \
+          pip install beautifulsoup4 justhtml brotli docutils jinja2 \
             mistune==0.8.4 pyjwt pytz whoosh
           # gpg on PyPi is currently broken with newer OS platform
           #   ubuntu 24.04
--- a/CHANGES.txt	Sat Dec 13 23:02:53 2025 -0500
+++ b/CHANGES.txt	Sun Dec 14 22:40:46 2025 -0500
@@ -64,6 +64,10 @@
   config.ini. (John Rouillard)
 - issue2551152 - added basic PGP setup/use info to admin_guide. (John
   Rouillard)
+- add support for the 'justhtml' html 5 parser library. It is written
+  in pure Python. Used to convert html emails into plain text. Faster
+  then beautifulsoup4 and it passes the html 5 standard browser test
+  suite. Beautifulsoup is still supported. (John Rouillard)
 
 2025-07-13 2.5.0
 
--- a/doc/installation.txt	Sat Dec 13 23:02:53 2025 -0500
+++ b/doc/installation.txt	Sun Dec 14 22:40:46 2025 -0500
@@ -311,6 +311,14 @@
   roundup-gettext, you must install polib_. See the `developer's
   guide`_ for details on translating your tracker.
 
+beautifulsoup, justhtml
+  When HTML only email is received, Roundup can convert it into
+  plain text using the native dehtml parser. To convert HTML
+  email into plain text, beautifulsoup4_ or justhtml_ can also be
+  used. You can choose the converter in the tracker's
+  config. Note that justhtml is pure Python, fast and conforms to
+  HTML 5 standards.
+  
 pywin32 - Windows Service
   You can run Roundup as a Windows service if pywin32_ is installed.
   Otherwise it must be started manually.
@@ -2423,6 +2431,7 @@
 .. _`adding MySQL users`:
     https://dev.mysql.com/doc/refman/8.0/en/creating-accounts.html
 .. _apache: https://httpd.apache.org/
+.. _beautifulsoup4: https://pypi.org/project/beautifulsoup4/
 .. _brotli: https://pypi.org/project/Brotli/
 .. _`developer's guide`: developers.html
 .. _defusedxml: https://pypi.org/project/defusedxml/
@@ -2430,6 +2439,7 @@
 .. _flup: https://pypi.org/project/flup/
 .. _gpg: https://www.gnupg.org/software/gpgme/index.html
 .. _jinja2: https://palletsprojects.com/projects/jinja/
+.. _justhtml: https://pypi.org/project/justhtml/
 .. _markdown: https://python-markdown.github.io/
 .. _markdown2: https://github.com/trentm/python-markdown2
 .. _mistune: https://pypi.org/project/mistune/
--- a/doc/tracker_config.txt	Sat Dec 13 23:02:53 2025 -0500
+++ b/doc/tracker_config.txt	Sun Dec 14 22:40:46 2025 -0500
@@ -1112,12 +1112,12 @@
 
   # If an email has only text/html parts, use this module
   # to convert the html to text. Choose from beautifulsoup 4,
-  # dehtml - (internal code), or none to disable conversion.
-  # If 'none' is selected, email without a text/plain part
-  # will be returned to the user with a message. If
+  # justhtml, dehtml - (internal code), or none to disable
+  # conversion. If 'none' is selected, email without a text/plain
+  # part will be returned to the user with a message. If
   # beautifulsoup is selected but not installed dehtml will
   # be used instead.
-  # Allowed values: beautifulsoup, dehtml, none
+  # Allowed values: beautifulsoup, justhtml, dehtml, none
   # Default: none
   convert_htmltotext = none
 
--- a/roundup/configuration.py	Sat Dec 13 23:02:53 2025 -0500
+++ b/roundup/configuration.py	Sun Dec 14 22:40:46 2025 -0500
@@ -384,17 +384,17 @@
 
     """What module should be used to convert emails with only text/html
     parts into text for display in roundup. Choose from beautifulsoup
-    4, dehtml - the internal code or none to disable html to text
-    conversion. If beautifulsoup chosen but not available, dehtml will
-    be used.
+    4, justhtml, dehtml - the internal code or none to disable html to
+    text conversion. If beautifulsoup or justhtml is chosen but not
+    available, dehtml will be used.
 
     """
 
-    class_description = "Allowed values: beautifulsoup, dehtml, none"
+    class_description = "Allowed values: beautifulsoup, justhtml, dehtml, none"
 
     def str2value(self, value):
         _val = value.lower()
-        if _val in ("beautifulsoup", "dehtml", "none"):
+        if _val in ("beautifulsoup", "justhtml", "dehtml", "none"):
             return _val
         else:
             raise OptionValueError(self, value, self.class_description)
@@ -1811,11 +1811,11 @@
         (HtmlToTextOption, "convert_htmltotext", "none",
             "If an email has only text/html parts, use this module\n"
             "to convert the html to text. Choose from beautifulsoup 4,\n"
-            "dehtml - (internal code), or none to disable conversion.\n"
-            "If 'none' is selected, email without a text/plain part\n"
-            "will be returned to the user with a message. If\n"
-            "beautifulsoup is selected but not installed dehtml will\n"
-            "be used instead."),
+            "justhtml, dehtml - (internal code), or none to disable\n"
+            "conversion. If 'none' is selected, email without a text/plain\n"
+            "part will be returned to the user with a message. If\n"
+            "beautifulsoup or justhtml is selected but not installed\n"
+            "dehtml will be used instead."),
         (BooleanOption, "keep_real_from", "no",
             "When handling emails ignore the Resent-From:-header\n"
             "and use the original senders From:-header instead.\n"
--- a/roundup/dehtml.py	Sat Dec 13 23:02:53 2025 -0500
+++ b/roundup/dehtml.py	Sun Dec 14 22:40:46 2025 -0500
@@ -5,6 +5,10 @@
 
 from roundup.anypy.strings import u2s, uchr
 
+# ruff PLC0415 ignore imports not at top of file
+# ruff RET505 ignore else  after return
+# ruff: noqa: PLC0415 RET505
+
 _pyver = sys.version_info[0]
 
 
@@ -29,6 +33,108 @@
                     return u2s(soup.get_text("\n", strip=True))
 
                 self.html2text = html2text
+            elif converter == "justhtml":
+                from justhtml import stream
+
+                def html2text(html):
+                    # The below does not work.
+                    # Using stream parser since I couldn't seem to strip
+                    # 'script' and 'style' blocks. But stream doesn't
+                    # have error reporting or stripping of text nodes
+                    # and dropping empty nodes. Also I would like to try
+                    # its GFM markdown output too even though it keeps
+                    # tables as html and doesn't completely covert as
+                    # this would work well for those supporting markdown.
+                    #
+                    #  ctx used for for testing since I have a truncated
+                    #  test doc. It eliminates error from missing DOCTYPE
+                    #  and head.
+                    #
+                    #from justhtml import JustHTML
+                    # from justhtml.context import FragmentContext
+                    #
+                    #ctx = FragmentContext('html')
+                    #justhtml = JustHTML(html,collect_errors=True,
+                    #                    fragment_context=ctx)
+                    # I still have the text output inside style/script tags.
+                    # with :not(style, script). I do get text contents
+                    # with query("style, script").
+                    #
+                    #return u2s("\n".join(
+                    #     [elem.to_text(separator="\n", strip=True)
+                    #        for elem in justhtml.query(":not(style, script)")])
+                    #          )
+
+                    # define inline elements so I can accumulate all unbroken
+                    # text in a single line with embedded inline elements.
+                    # 'br' is inline but should be treated it as a line break
+                    # and element before/after should not be accumulated
+                    # together.
+                    inline_elements = (
+                        "a",
+                        "address",
+                        "b",
+                        "cite",
+                        "code",
+                        "em",
+                        "i",
+                        "img",
+                        "mark",
+                        "q",
+                        "s",
+                        "small",
+                        "span",
+                        "strong",
+                        "sub",
+                        "sup",
+                        "time")
+
+                    # each line is appended and joined at the end
+                    text = []
+                    # the accumulator for all text in inline elements
+                    text_accumulator = ""
+                    # if set skip all lines till matching end tag found
+                    # used to skip script/style blocks
+                    skip_till_endtag = None
+                    # used to force text_accumulator into text with added
+                    # newline so we have a blank line between paragraphs.
+                    _need_parabreak = False
+
+                    for event, data in stream(html):
+                        if event == "end" and skip_till_endtag == data:
+                            skip_till_endtag = None
+                            continue
+                        if skip_till_endtag:
+                            continue
+                        if (event == "start" and
+                              data[0] in ('script', 'style')):
+                            skip_till_endtag = data[0]
+                            continue
+                        if (event == "start" and
+                              text_accumulator and
+                              data[0] not in inline_elements):
+                            # add accumulator to "text"
+                            text.append(text_accumulator)
+                            text_accumulator = ""
+                            _need_parabreak = False
+                        elif event == "text":
+                            if not data.isspace():
+                                text_accumulator = text_accumulator + data
+                                _need_parabreak = True
+                        elif (_need_parabreak and
+                              event == "start" and
+                              data[0] == "p"):
+                            text.append(text_accumulator + "\n")
+                            text_accumulator = ""
+                            _need_parabreak = False
+
+                    # save anything left in the accumulator at end of document
+                    if text_accumulator:
+                        # add newline to match dehtml and beautifulsoup
+                        text.append(text_accumulator + "\n")
+                    return u2s("\n".join(text))
+
+                self.html2text = html2text
             else:
                 raise ImportError
         except ImportError:
@@ -96,6 +202,16 @@
 
 
 if __name__ == "__main__":
+    # ruff: noqa: B011 S101
+
+    try:
+        assert False
+    except AssertionError:
+        pass
+    else:
+        print("Error, assertions turned off. Test fails")
+        sys.exit(1)
+
     html = """
 <body>
 <script>
@@ -128,10 +244,10 @@
 <li class="toctree-l2"><a class="reference internal" href="admin_guide.html">Administration Guide</a></li>
 </ul>
 <div class="section" id="prerequisites">
-<h2><a class="toc-backref" href="#id5">Prerequisites</a></h2>
+<H2><a class="toc-backref" href="#id5">Prerequisites</a></H2>
 <p>Roundup requires Python 2.5 or newer (but not Python 3) with a functioning
 anydbm module. Download the latest version from <a class="reference external" href="http://www.python.org/">http://www.python.org/</a>.
-It is highly recommended that users install the latest patch version
+It is highly recommended that users install the <span>latest patch version</span>
 of python as these contain many fixes to serious bugs.</p>
 <p>Some variants of Linux will need an additional &#8220;python dev&#8221; package
 installed for Roundup installation to work. Debian and derivatives, are
@@ -147,18 +263,42 @@
 </body>
 """
 
-    html2text = dehtml("dehtml").html2text
-    if html2text:
-        print(html2text(html))
+    if len(sys.argv) > 1:
+        with open(sys.argv[1]) as h:
+            html = h.read()
 
+    print("==== beautifulsoup")
     try:
         # trap error seen if N_TOKENS not defined when run.
         html2text = dehtml("beautifulsoup").html2text
         if html2text:
-            print(html2text(html))
+            text = html2text(html)
+            assert ('HELP' not in text)
+            assert ('display:block' not in text)
+            print(text)
     except NameError as e:
         print("captured error %s" % e)
 
+    print("==== justhtml")
+    try:
+        html2text = dehtml("justhtml").html2text
+        if html2text:
+            text = html2text(html)
+            assert ('HELP' not in text)
+            assert ('display:block' not in text)
+            print(text)
+    except NameError as e:
+        print("captured error %s" % e)
+
+    print("==== dehtml")
+    html2text = dehtml("dehtml").html2text
+    if html2text:
+        text = html2text(html)
+        assert ('HELP' not in text)
+        assert ('display:block' not in text)
+        print(text)
+
+    print("==== disabled html -> text conversion")
     html2text = dehtml("none").html2text
     if html2text:
         print("FAIL: Error, dehtml(none) is returning a function")
--- a/test/test_mailgw.py	Sat Dec 13 23:02:53 2025 -0500
+++ b/test/test_mailgw.py	Sun Dec 14 22:40:46 2025 -0500
@@ -35,6 +35,13 @@
     skip_beautifulsoup = mark_class(pytest.mark.skip(
         reason="Skipping beautifulsoup tests: 'bs4' not installed"))
 
+try:
+    import justhtml
+    skip_justhtml = lambda func, *args, **kwargs: func
+except ImportError:
+    from .pytest_patcher import mark_class
+    skip_justhtml = mark_class(pytest.mark.skip(
+        reason="Skipping justhtml tests: 'justhtml' not installed"))
 
 from roundup.anypy.email_ import message_from_bytes
 from roundup.anypy.strings import b2s, u2s, s2b
@@ -315,6 +322,10 @@
     def testTextHtmlMessageBeautifulSoup(self):
         self.testTextHtmlMessage(converter='beautifulsoup')
 
+    @skip_justhtml
+    def testTextHtmlMessageJusthtml(self):
+        self.testTextHtmlMessage(converter='justhtml')
+        
     def testTextHtmlMessage(self, converter='dehtml'):
         html_message='''Content-Type: text/html;
   charset="iso-8859-1"
@@ -375,10 +386,15 @@
         text_fragments['dehtml'] = ['Roundup\n        Home\nDownload\nDocs\nRoundup Features\nInstalling Roundup\nUpgrading to newer versions of Roundup\nRoundup FAQ\nUser Guide\nCustomising Roundup\nAdministration Guide\nPrerequisites\n\nRoundup requires Python 2.6 or newer (but not Python 3) with a functioning\nanydbm module. Download the latest version from http://www.python.org/.\nIt is highly recommended that users install the latest patch version\nof python as these contain many fixes to serious bugs.\n\nSome variants of Linux will need an additional ', ('python dev', u2s(u'\u201cpython dev\u201d')), ' package\ninstalled for Roundup installation to work. Debian and derivatives, are\nknown to require this.\n\nIf you', (u2s(u'\u2019'), ''), 're on windows, you will either need to be using the ActiveState python\ndistribution (at http://www.activestate.com/Products/ActivePython/), or you', (u2s(u'\u2019'), ''), 'll\nhave to install the win32all package separately (get it from\nhttp://starship.python.net/crew/mhammond/win32/).']
         text_fragments['beautifulsoup'] = ['Roundup\nHome\nDownload\nDocs\nRoundup Features\nInstalling Roundup\nUpgrading to newer versions of Roundup\nRoundup FAQ\nUser Guide\nCustomising Roundup\nAdministration Guide\nPrerequisites\nRoundup requires Python 2.6 or newer (but not Python 3) with a functioning\nanydbm module. Download the latest version from\nhttp://www.python.org/\n.\nIt is highly recommended that users install the latest patch version\nof python as these contain many fixes to serious bugs.\nSome variants of Linux will need an additional ', ('python dev', u2s(u'\u201cpython dev\u201d')), ' package\ninstalled for Roundup installation to work. Debian and derivatives, are\nknown to require this.\nIf you', (u2s(u'\u2019'), "'"), 're on windows, you will either need to be using the ActiveState python\ndistribution (at\nhttp://www.activestate.com/Products/ActivePython/\n), or you’ll\nhave to install the win32all package separately (get it from\nhttp://starship.python.net/crew/mhammond/win32/\n).']
 
+        text_fragments['justhtml'] = ['Roundup\nHome\nDownload\nDocs\nRoundup Features\nInstalling Roundup\nUpgrading to newer versions of Roundup\nRoundup FAQ\nUser Guide\nCustomising Roundup\nAdministration Guide\nPrerequisites\nRoundup requires Python 2.6 or newer (but not Python 3) with a functioning\nanydbm module. Download the latest version from http://www.python.org/.\nIt is highly recommended that users install the latest patch version\nof python as these contain many fixes to serious bugs.\nSome variants of Linux will need an additional ', ('python dev', u2s(u'\u201cpython dev\u201d')), ' package\ninstalled for Roundup installation to work. Debian and derivatives, are\nknown to require this.\nIf you', (u2s(u'\u2019'), "'"), 're on windows, you will either need to be using the ActiveState python\ndistribution (at http://www.activestate.com/Products/ActivePython/), or you’ll\nhave to install the win32all package separately (get it from\nhttp://starship.python.net/crew/mhammond/win32/).']
+        self.maxDiff = 100000
         self.db.config.MAILGW_CONVERT_HTMLTOTEXT = converter
         nodeid = self._handle_mail(html_message)
         assert not os.path.exists(SENDMAILDEBUG)
         msgid = self.db.issue.get(nodeid, 'messages')[0]
+        print(self.db.msg.get(msgid, 'content'))
+        print("\n==== fragment\n")
+        print(text_fragments[converter])
         self.compareStringFragments(self.db.msg.get(msgid, 'content'),
                                     text_fragments[converter])
 

Roundup Issue Tracker: http://roundup-tracker.org/