Skip to content
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
40a7b07
feat(email formats): add 2 new features to emails: callouts and CTAs
andrewleith Jan 16, 2026
8f28130
fix(cta/callout): make callout border darker; make cta text work cros…
andrewleith Jan 16, 2026
1ee5287
fix(gmail): control link text color
andrewleith Jan 16, 2026
d712e1e
fix(links): make links black in gmail
andrewleith Jan 16, 2026
05a4f80
fix(formatters): remove !important from link style in email formatting
andrewleith Jan 16, 2026
b7f2df4
debug(link color): trying various fixes
andrewleith Jan 16, 2026
943ad22
debug(link color): try something else
andrewleith Jan 16, 2026
d925f44
feat(link color): another attempt
andrewleith Jan 16, 2026
09890bf
fix(gmail): override link color
andrewleith Jan 16, 2026
b6f4710
feat(email): override Gmail's link color for CTA buttons
andrewleith Jan 16, 2026
068fe25
feat(cta): enhance link styling with !important for color and text-de…
andrewleith Jan 16, 2026
20b4ea7
refactor(email): undo non-working Gmail link color overrides
andrewleith Jan 16, 2026
a4f8007
fix: removed unsused delcaration
andrewleith Jan 16, 2026
a5273c1
feat(email): add table support
andrewleith Mar 13, 2026
e79c8e9
Merge branch 'main' into experiment/add-email-features
andrewleith Mar 13, 2026
b2359bd
feat(email): update table header style with background color
andrewleith Mar 13, 2026
61319d2
Merge branch 'main' into experiment/add-email-features
andrewleith Mar 25, 2026
e1b2c49
feat(email): implement feature flags for tables and callouts in email…
andrewleith Mar 30, 2026
def5f9c
fix(email): correct feature flag naming for CTA functionality
andrewleith Mar 30, 2026
d85db9a
fix(test): fix failing callout test
andrewleith Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 190 additions & 1 deletion notifications_utils/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import bleach
import mistune
import smartypants
from flask import Markup
from flask import Markup, current_app

from notifications_utils.sanitise_text import SanitiseSMS

Expand All @@ -33,12 +33,20 @@
EN_CLOSE = r"\[\[/en\]\]" # matches [[/en]]
RTL_OPEN = r"\[\[rtl\]\]" # matches [[rtl]]
RTL_CLOSE = r"\[\[/rtl\]\]" # matches [[/rtl]]
CALLOUT_OPEN = r"\[\[callout\]\]" # matches [[callout]]
CALLOUT_CLOSE = r"\[\[/callout\]\]" # matches [[/callout]]
CTA_OPEN = r"\[\[cta\]\]" # matches [[cta]]
CTA_CLOSE = r"\[\[/cta\]\]" # matches [[/cta]]
FR_OPEN_LITERAL = "[[fr]]"
FR_CLOSE_LITERAL = "[[/fr]]"
EN_OPEN_LITERAL = "[[en]]"
EN_CLOSE_LITERAL = "[[/en]]"
RTL_OPEN_LITERAL = "[[rtl]]"
RTL_CLOSE_LITERAL = "[[/rtl]]"
CALLOUT_OPEN_LITERAL = "[[callout]]"
CALLOUT_CLOSE_LITERAL = "[[/callout]]"
CTA_OPEN_LITERAL = "[[cta]]"
CTA_CLOSE_LITERAL = "[[/cta]]"
BR_TAG = r"<br\s?/>"


Expand Down Expand Up @@ -488,8 +496,42 @@ def double_emphasis(self, text):
def emphasis(self, text):
return f"<em>{text}</em>"

def table(self, header, body):
# If the feature flag is off, return the content with the tags unprocessed. This allows us to add table tags to
# content without them being rendered as tables until we're ready to turn the feature flag on
try:
if not current_app.config.get("FF_EMAIL_TABLES", False):
return ""
except RuntimeError:
return ""
return (
'<table style="Margin: 0 0 20px 0; border-collapse: collapse; width: 100%; font-size: 19px; line-height: 25px; color: #0B0C0C;">'
f"<thead>{header}</thead>"
f"<tbody>{body}</tbody>"
"</table>"
)

def table_row(self, content):
return f"<tr>{content}</tr>"

def table_cell(self, content, **flags):
if flags.get("header"):
return (
'<th style="text-align: left; border: 1px solid #BFC1C3; '
'padding: 8px; font-weight: bold; background: #fffdf5;"'
f">{content}</th>"
)

align = flags.get("align")
align_style = f"text-align: {align}; " if align else ""
return f'<td style="{align_style}border: 1px solid #BFC1C3; padding: 8px;">' f"{content}</td>"


class NotifyPlainTextEmailMarkdownRenderer(NotifyEmailMarkdownRenderer):
_TABLE_CELL_SEPARATOR = "\u241f"
_TABLE_HEADER_PREFIX = "__TABLE_HEADER__:"
_TABLE_CELL_PREFIX = "__TABLE_CELL__:"

COLUMN_WIDTH = 65

def header(self, text, level, raw=None):
Expand Down Expand Up @@ -570,6 +612,37 @@ def double_emphasis(self, text):
def emphasis(self, text):
return f"_{text}_"

def table(self, header, body):
# If the feature flag is off, return the content with the tags unprocessed. This allows us to add table tags to
# content without them being rendered as tables until we're ready to turn the feature flag on
try:
if not current_app.config.get("FF_EMAIL_TABLES", False):
return ""
except RuntimeError:
return ""
return "".join((self.linebreak() * 2, header, body.rstrip("\n")))

def table_row(self, content):
cells_with_markers = [cell for cell in content.split(self._TABLE_CELL_SEPARATOR) if cell]
if not cells_with_markers:
return ""

is_header = all(cell.startswith(self._TABLE_HEADER_PREFIX) for cell in cells_with_markers)
cells = [
cell.replace(self._TABLE_HEADER_PREFIX, "", 1).replace(self._TABLE_CELL_PREFIX, "", 1) for cell in cells_with_markers
]

row = f"| {' | '.join(cells)} |"
if is_header:
separator = f"| {' | '.join(['---'] * len(cells))} |"
return f"{row}\n{separator}\n"

return f"{row}\n"

def table_cell(self, content, **flags):
prefix = self._TABLE_HEADER_PREFIX if flags.get("header") else self._TABLE_CELL_PREFIX
return f"{prefix}{content}{self._TABLE_CELL_SEPARATOR}"


class NotifyEmailPreheaderMarkdownRenderer(NotifyPlainTextEmailMarkdownRenderer):
def header(self, text, level, raw=None):
Expand Down Expand Up @@ -685,6 +758,122 @@ def remove_rtl_divs(_content: str) -> str:
return remove_tags(_content, RTL_OPEN, RTL_CLOSE)


def escape_callout_tags(_content: str) -> str:
"""
Escape callout tags into code tags in the content so mistune doesn't put them inside p tags. This makes it simple
to replace them afterwards, and avoids creating invalid HTML in the process
"""

# check to ensure we have the same number of opening and closing tags before escaping tags
if _content.count(CALLOUT_OPEN_LITERAL) == _content.count(CALLOUT_CLOSE_LITERAL):
_content = _content.replace(CALLOUT_OPEN_LITERAL, f"\n```\n{CALLOUT_OPEN_LITERAL}\n```\n")
_content = _content.replace(CALLOUT_CLOSE_LITERAL, f"\n```\n{CALLOUT_CLOSE_LITERAL}\n```\n")

return _content


def add_callout_divs(_content: str) -> str:
"""
Custom parser to add the callout divs.

String replace callout tags in-place with styled div elements.
"""
# If the feature flag is off, return the content with the tags unprocessed. This allows us to add callout tags to
# content without them being rendered as callout divs until we're ready to turn the feature flag on.
try:
if not current_app.config.get("FF_EMAIL_CALLOUTS", False):
return _content
except RuntimeError:
return _content

# check to ensure we have the same number of opening and closing tags before replacing tags
if _content.count(CALLOUT_OPEN_LITERAL) == _content.count(CALLOUT_CLOSE_LITERAL):
_content = _content.replace(
CALLOUT_OPEN_LITERAL,
'<div style="margin-bottom: 20px; background: #fffdf5; padding: 15px 15px 0 15px; border-radius: 10px; box-shadow: 0 1px 3px #0000000d, 0 1px 2px #0000001a; border: 1px solid #dcd6d6">',
)
_content = _content.replace(CALLOUT_CLOSE_LITERAL, "</div>")

return _content


def remove_callout_divs(_content: str) -> str:
"""Remove the tags from content. This fn is for use in the email
preheader, since this is plain text not html"""
return remove_tags(_content, CALLOUT_OPEN, CALLOUT_CLOSE)


def escape_cta_tags(_content: str) -> str:
"""
Escape CTA tags into code tags in the content so mistune doesn't put them inside p tags. This makes it simple
to replace them afterwards, and avoids creating invalid HTML in the process
"""

# check to ensure we have the same number of opening and closing tags before escaping tags
if _content.count(CTA_OPEN_LITERAL) == _content.count(CTA_CLOSE_LITERAL):
_content = _content.replace(CTA_OPEN_LITERAL, f"\n```\n{CTA_OPEN_LITERAL}\n```\n")
_content = _content.replace(CTA_CLOSE_LITERAL, f"\n```\n{CTA_CLOSE_LITERAL}\n```\n")

return _content


def add_cta_buttons(_content: str) -> str:
"""
Custom parser to add CTA button divs.

String replace CTA tags in-place with styled div elements, but only if the content
contains exactly one link (<a> tag). If zero or multiple links, leave tags unprocessed.
"""
# If the feature flag is off, return the content with the tags unprocessed. This allows us to add CTA tags to
# content without them being rendered as CTA buttons until we're ready to turn the feature flag on
try:
if not current_app.config.get("FF_EMAIL_CTA", False):
return _content
except RuntimeError:
return _content

# check to ensure we have the same number of opening and closing tags before replacing tags
if _content.count(CTA_OPEN_LITERAL) == _content.count(CTA_CLOSE_LITERAL):
# Find all CTA blocks and validate each one
result = _content
import re as regex_module

# Pattern to match CTA blocks
cta_pattern = regex_module.compile(r"\[\[cta\]\](.*?)\[\[/cta\]\]", regex_module.DOTALL)

def replace_cta(match):
cta_content = match.group(1)
# Count <a> tags in this CTA block
link_count = cta_content.count("<a ")

# Only replace if exactly one link
if link_count == 1:
# Add text-decoration: none and color to the <a> tag with !important
link_styled_content = regex_module.sub(
r'<a style="([^"]*)"',
r'<a style="text-decoration: none !important; color: #393939 !important; \1"',
cta_content,
)
# Add color and remove margin from the <p> tag
link_styled_content = regex_module.sub(r'<p style="[^"]*"', r'<p style="Margin: 0;"', link_styled_content)
button_style = "margin-bottom: 20px; border-radius: 4px; background: #ffbf47; padding: 0.55em 1em 0.45em; text-align: center; display: inline-block; cursor: pointer;"
return f'<div style="{button_style}">{link_styled_content}</div>'
else:
# Leave unprocessed if not exactly one link
return match.group(0)

result = cta_pattern.sub(replace_cta, result)
return result

return _content


def remove_cta_tags(_content: str) -> str:
"""Remove the tags from content. This fn is for use in the email
preheader, since this is plain text not html"""
return remove_tags(_content, CTA_OPEN, CTA_CLOSE)


def remove_tags(_content: str, *tags) -> str:
"""Remove the tags in parameters from content.

Expand Down
12 changes: 12 additions & 0 deletions notifications_utils/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@
from notifications_utils.columns import Columns
from notifications_utils.field import Field
from notifications_utils.formatters import (
add_callout_divs,
add_cta_buttons,
add_language_divs,
add_prefix,
add_rtl_divs,
add_trailing_newline,
autolink_sms,
escape_callout_tags,
escape_cta_tags,
escape_html,
escape_lang_tags,
escape_rtl_tags,
Expand All @@ -28,6 +32,8 @@
notify_email_preheader_markdown,
notify_letter_preview_markdown,
notify_plain_text_email_markdown,
remove_callout_divs,
remove_cta_tags,
remove_empty_lines,
remove_language_divs,
remove_nested_list_padding,
Expand Down Expand Up @@ -423,6 +429,8 @@ def preheader(self):
.then(notify_email_preheader_markdown)
.then(remove_language_divs)
.then(remove_rtl_divs)
.then(remove_callout_divs)
.then(remove_cta_tags)
.then(do_nice_typography)
.split()
)[: self.PREHEADER_LENGTH_IN_CHARACTERS].strip()
Expand Down Expand Up @@ -857,10 +865,14 @@ def get_html_email_body(template_content, template_values, redact_missing_person
.then(add_trailing_newline)
.then(escape_lang_tags)
.then(escape_rtl_tags)
.then(escape_callout_tags)
.then(escape_cta_tags)
.then(notify_email_markdown)
.then(remove_nested_list_padding)
.then(add_language_divs)
.then(add_rtl_divs)
.then(add_callout_divs)
.then(add_cta_buttons)
.then(do_nice_typography)
)

Expand Down
40 changes: 35 additions & 5 deletions tests/test_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,11 +557,41 @@ def test_multiple_newlines_get_truncated(markdown_function, expected):
assert markdown_function("before\n\n\n\n\n\nafter") == expected


@pytest.mark.parametrize(
"markdown_function", (notify_letter_preview_markdown, notify_email_markdown, notify_plain_text_email_markdown)
)
def test_table(markdown_function):
assert markdown_function("col | col\n" "----|----\n" "val | val\n") == ("")
def test_table_feature_off():
markdown_input = "col | col\n" "----|----\n" "val | val\n"

assert notify_letter_preview_markdown(markdown_input) == ""

# Tables are gated behind FF_EMAIL_TABLES — off by default (no app context)
email_result = notify_email_markdown(markdown_input)
assert "<table" not in email_result
assert "<th" not in email_result
assert "<td" not in email_result

plain_text_result = notify_plain_text_email_markdown(markdown_input)
assert "| col | col |" not in plain_text_result
assert "| --- | --- |" not in plain_text_result
assert "| val | val |" not in plain_text_result


def test_table_feature_on(app):
app.config["FF_EMAIL_TABLES"] = True
markdown_input = "col | col\n" "----|----\n" "val | val\n"

assert notify_letter_preview_markdown(markdown_input) == ""

email_result = notify_email_markdown(markdown_input)
assert "<table" in email_result
assert "<th" in email_result
assert "<td" in email_result
assert "background: #fffdf5" in email_result
assert "col" in email_result
assert "val" in email_result

plain_text_result = notify_plain_text_email_markdown(markdown_input)
assert "| col | col |" in plain_text_result
assert "| --- | --- |" in plain_text_result
assert "| val | val |" in plain_text_result


@pytest.mark.parametrize(
Expand Down
Loading
Loading