diff --git a/bodhi-server/bodhi/server/mail.py b/bodhi-server/bodhi/server/mail.py index a6303cac5e..b3b96fd775 100644 --- a/bodhi-server/bodhi/server/mail.py +++ b/bodhi-server/bodhi/server/mail.py @@ -17,14 +17,13 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. """A collection of utilities for sending e-mail to Bodhi users.""" -from textwrap import wrap import os import smtplib import typing from bodhi.server import log from bodhi.server.config import config -from bodhi.server.util import get_rpm_header, get_absolute_path +from bodhi.server.util import get_rpm_header, get_absolute_path, markdown_to_text, wrap_text if typing.TYPE_CHECKING: # pragma: no cover from bodhi.server.models import Update # noqa: 401 @@ -307,8 +306,8 @@ def get_template(update: 'Update', use_template: str = 'fedora_errata_template') info['product'] = update.release.long_name info['notes'] = "" if update.notes and len(update.notes): - info['notes'] = "Update Information:\n\n%s\n" % \ - '\n'.join(wrap(update.notes, width=80)) + plaintext = markdown_to_text(update.notes) + info['notes'] = f"Update Information:\n\n{wrap_text(plaintext)}\n" info['notes'] += line # Add this update's referenced Bugzillas diff --git a/bodhi-server/bodhi/server/models.py b/bodhi-server/bodhi/server/models.py index 84cc0e02e3..f27c75b065 100644 --- a/bodhi-server/bodhi/server/models.py +++ b/bodhi-server/bodhi/server/models.py @@ -81,6 +81,8 @@ pagure_api_get, tokenize, build_names_by_type, + markdown_to_text, + wrap_text, ) @@ -2971,15 +2973,10 @@ def get_bugstring(self, show_titles=False): """ val = '' if show_titles: - i = 0 + bugstr = [] for bug in self.bugs: - bugstr = '%s%s - %s\n' % ( - i and ' ' * 11 + ': ' or '', bug.bug_id, bug.title) - val += '\n'.join(wrap( - bugstr, width=67, - subsequent_indent=' ' * 11 + ': ')) + '\n' - i += 1 - val = val[:-1] + bugstr.append(f"{bug.bug_id} - {bug.title}") + val = '\n'.join(bugstr) else: val = ' '.join([str(bug.bug_id) for bug in self.bugs]) return val @@ -3435,45 +3432,51 @@ def __str__(self): Returns: str: A string representation of the update. """ - val = "%s\n%s\n%s\n" % ('=' * 80, '\n'.join(wrap( - self.alias, width=80, initial_indent=' ' * 5, - subsequent_indent=' ' * 5)), '=' * 80) - val += """ Release: %s - Status: %s - Type: %s - Severity: %s - Karma: %d""" % (self.release.long_name, self.status.description, - self.type.description, self.severity, self.karma) + nl = '\n' + val = f"""{'=' * 80} +{nl.join(wrap(self.alias, width=79, initial_indent=' ' * 5, subsequent_indent=' ' * 5))} +{'=' * 80} +{'Release:' : >12} {self.release.long_name} +{'Status:' : >12} {self.status.description} +{'Type:' : >12} {self.type.description} +{'Severity:' : >12} {self.severity} +{'Karma:' : >12} {self.karma}""" if self.critpath: - val += "\n Critpath: %s" % self.critpath + val += f"{nl}{'Critpath:' : >12} {self.critpath}" if self.request is not None: - val += "\n Request: %s" % self.request.description + val += f"{nl}{'Request:' : >12} {self.request.description}" if self.bugs: - bugs = self.get_bugstring(show_titles=True) - val += "\n Bugs: %s" % bugs + bugs = wrap_text( + self.get_bugstring(show_titles=True), width=79, + initial_indent=f"{'Bugs:' : >12} ", + subsequent_indent=f"{' ' * 13}") + val += f"{nl}{bugs}" if self.notes: - notes = wrap( - self.notes, width=67, subsequent_indent=' ' * 11 + ': ') - val += "\n Notes: %s" % '\n'.join(notes) + notes = wrap_text( + markdown_to_text(self.notes).strip(), width=79, + initial_indent=f"{'Notes:' : >12} ", + subsequent_indent=f"{' ' * 13}") + val += f"{nl}{notes}" username = None if self.user: username = self.user.name - val += """ - Submitter: %s - Submitted: %s\n""" % (username, self.date_submitted) + val += f""" +{'Submitter:' : >12} {username} +{'Submitted:' : >12} {self.date_submitted} +""" if self.comments_since_karma_reset: - val += " Comments: " - comments = [] - for comment in self.comments_since_karma_reset: - comments.append("%s%s - %s (karma %s)" % (' ' * 13, - comment.user.name, comment.timestamp, - comment.karma)) + comments_list = [] + for comment in reversed(self.comments_since_karma_reset): + comments_list.append(f"{comment.user.name} - {comment.timestamp} " + f"(karma {comment.karma})") if comment.text: - text = wrap(comment.text, initial_indent=' ' * 13, - subsequent_indent=' ' * 13, width=67) - comments.append('\n'.join(text)) - val += '\n'.join(comments).lstrip() + '\n' - val += "\n %s\n" % self.abs_url() + comments_list.append(comment.text) + comments = wrap_text( + '\n'.join(comments_list), width=79, + initial_indent=f"{'Comments:' : >12} ", + subsequent_indent=f"{' ' * 13}") + val += f"{comments}{nl}" + val += f"{nl} {self.abs_url()}" return val def update_bugs(self, bug_ids, session): diff --git a/bodhi-server/bodhi/server/util.py b/bodhi-server/bodhi/server/util.py index b363e4f749..701bced79a 100644 --- a/bodhi-server/bodhi/server/util.py +++ b/bodhi-server/bodhi/server/util.py @@ -21,6 +21,7 @@ from contextlib import contextmanager from datetime import datetime, timedelta from importlib import import_module +from textwrap import TextWrapper from urllib.parse import urlencode import bz2 import errno @@ -38,6 +39,7 @@ import types import typing +from bs4 import BeautifulSoup from pyramid.i18n import TranslationStringFactory import arrow import bleach @@ -1362,3 +1364,46 @@ def eol_releases(days: int = 30) -> list: eol_releases.append((release.long_name, release.eol)) return eol_releases + + +def markdown_to_text(markdown_string: str) -> str: + """ + Convert a markdown string to plaintext. + + Credit about this method goes to Github gist at + https://gist.github.com/lorey/eb15a7f3338f959a78cc3661fbc255fe + + Args: + markdown_string: a markdown formatted text. + Returns: + Text with markdown tags stripped out. + """ + html = markdown.markdown(markdown_string, extensions=['fenced_code']) + + # extract text + soup = BeautifulSoup(html, "html.parser") + text = ''.join(soup.findAll(string=True)) + + return text + + +def wrap_text(text: str, width: int = 80, initial_indent: str = '', + subsequent_indent: str = '', **kwargs) -> str: + """ + Wrap text to the specified line length preserving existing newlines. + + Args: + text: the text that needs to be wrapped. + width: the maximum line length. + Returns: + Text wrapped at the desired length. + """ + wrapper = TextWrapper(width=width, subsequent_indent=subsequent_indent, **kwargs) + + paragraphs = [] + for i, paragraph in enumerate(text.splitlines()): + paragraphs.extend(wrapper.wrap(f"{not i and initial_indent or ''}" + f"{i and subsequent_indent or ''}" + f"{paragraph}")) + + return '\n'.join(paragraphs) diff --git a/bodhi-server/pyproject.toml b/bodhi-server/pyproject.toml index 033a52591c..66995e84ab 100644 --- a/bodhi-server/pyproject.toml +++ b/bodhi-server/pyproject.toml @@ -84,6 +84,7 @@ alembic = ">=1.5.5" arrow = ">=0.17.0" authlib = ">=0.15.4" backoff = ">=1.10.0" +beautifulsoup4 = "^4.12.0" bleach = ">=3.2.3" bodhi-messages = "^7.0" celery = ">=5.2.1" diff --git a/bodhi-server/tests/test_mail.py b/bodhi-server/tests/test_mail.py index ed6320e972..617b21a883 100644 --- a/bodhi-server/tests/test_mail.py +++ b/bodhi-server/tests/test_mail.py @@ -143,6 +143,25 @@ def test_testing_update(self): # The advisory flag should be included in the dnf instructions. assert 'dnf --enablerepo=updates-testing upgrade --advisory {}'.format(u.alias) in t + def test_no_markdown(self): + """Update notes should be sent in plaintext.""" + u = models.Update.query.first() + u.notes = """Some **fancy** update description: + +- first element +- second element + +Let's also have some code: +`````` +""" + + t = mail.get_template(u) + + # Assemble the template for easier asserting. + t = '\n'.join([line for line in t[0]]) + assert 'Some fancy update description:' in t + assert '``````' not in t + def test_read_template(self): """Ensure that email template is read correctly.""" tpl_name = "maillist_template"