diff --git a/mypy.ini b/mypy.ini index fa1afb0b..136f4c79 100644 --- a/mypy.ini +++ b/mypy.ini @@ -36,4 +36,7 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-phonenumbers.*] +ignore_missing_imports = True + +[mypy-py_w3c.*] ignore_missing_imports = True \ No newline at end of file diff --git a/notifications_utils/template.py b/notifications_utils/template.py index bf387d83..b5d8d2b6 100644 --- a/notifications_utils/template.py +++ b/notifications_utils/template.py @@ -46,6 +46,7 @@ from notifications_utils.take import Take from notifications_utils.template_change import TemplateChange from notifications_utils.sanitise_text import SanitiseSMS +from notifications_utils.validate_html import check_if_string_contains_valid_html template_env = Environment( @@ -351,6 +352,7 @@ def __init__( logo_with_background_colour=False, brand_name=None, jinja_path=None, + allow_html=False, ): super().__init__(template, values, jinja_path=jinja_path) self.fip_banner_english = fip_banner_english @@ -361,6 +363,7 @@ def __init__( self.brand_colour = brand_colour self.logo_with_background_colour = logo_with_background_colour self.brand_name = brand_name + self.allow_html = allow_html # set this again to make sure the correct either utils / downstream local jinja is used # however, don't set if we are in a test environment (to preserve the above mock) if "pytest" not in sys.modules: @@ -390,7 +393,7 @@ def __str__(self): return self.jinja_template.render( { - "body": get_html_email_body(self.content, self.values), + "body": get_html_email_body(self.content, self.values, html="passthrough" if self.allow_html else "escape"), "preheader": self.preheader, "fip_banner_english": self.fip_banner_english, "fip_banner_french": self.fip_banner_french, @@ -423,6 +426,7 @@ def __init__( brand_name=None, logo_with_background_colour=None, asset_domain=None, + allow_html=False, ): super().__init__( template, @@ -442,6 +446,7 @@ def __init__( self.brand_text = brand_text self.brand_name = brand_name self.asset_domain = asset_domain or "assets.notification.canada.ca" + self.allow_html = allow_html def __str__(self): return Markup( @@ -451,6 +456,7 @@ def __str__(self): self.content, self.values, redact_missing_personalisation=self.redact_missing_personalisation, + html="passthrough" if self.allow_html else "escape", ), "subject": self.subject, "from_name": escape_html(self.from_name), @@ -724,14 +730,17 @@ def is_unicode(content): return set(content) & set(SanitiseSMS.WELSH_NON_GSM_CHARACTERS) -def get_html_email_body(template_content, template_values, redact_missing_personalisation=False): +def get_html_email_body(template_content, template_values, redact_missing_personalisation=False, html="escape"): + if html == "passthrough" and check_if_string_contains_valid_html(template_content) != []: + # template_content contains invalid html, so escape it + html = "escape" return ( Take( Field( template_content, template_values, - html="escape", + html=html, markdown_lists=True, redact_missing_personalisation=redact_missing_personalisation, ) diff --git a/notifications_utils/validate_html.py b/notifications_utils/validate_html.py new file mode 100644 index 00000000..6f21335c --- /dev/null +++ b/notifications_utils/validate_html.py @@ -0,0 +1,27 @@ +from py_w3c.validators.html.validator import HTMLValidator + + +def check_if_string_contains_valid_html(content: str) -> list: + """ + Check if html snippet is valid - returns [] if html is valid. + This is only a partial document, so we expect the Doctype and title to be missing. + """ + + allowed_errors = [ + "Start tag seen without seeing a doctype first. Expected “”.", + "Element “head” is missing a required instance of child element “title”.", + ] + + # the content can contain markdown as well as html - wrap the content in a div so it has a chance of being valid html + content_in_div = f"