emails: Replace Premailer with CSS-inline.

Primary goal of library replacement is improving execution speed.
This commit should not affect the functionality of the system
or make any changes to it.
This commit is contained in:
Daniil Fadeev 2023-04-03 19:30:03 +04:00 committed by Tim Abbott
parent dceb49011a
commit 5c6f842b28
8 changed files with 62 additions and 65 deletions

View File

@ -131,7 +131,7 @@ limited CSS support and generally require us to inject any CSS we're
using in the emails into the email as inline styles. And then you using in the emails into the email as inline styles. And then you
also need both plain-text and HTML emails. We solve these problems also need both plain-text and HTML emails. We solve these problems
using a combination of the using a combination of the
[premailer](https://github.com/peterbe/premailer) library and having [css-inline](https://github.com/Stranger6667/css-inline) library and having
two copies of each email (plain-text and HTML). two copies of each email (plain-text and HTML).
So for each email, there are two source templates: the `.txt` version So for each email, there are two source templates: the `.txt` version
@ -139,7 +139,7 @@ So for each email, there are two source templates: the `.txt` version
`.txt` version is used directly; while the `.source.html` template is `.txt` version is used directly; while the `.source.html` template is
processed by `scripts/setup/inline_email_css.py` (generating a `.html` template processed by `scripts/setup/inline_email_css.py` (generating a `.html` template
under `templates/zerver/emails/compiled`); that tool (powered by under `templates/zerver/emails/compiled`); that tool (powered by
`premailer`) injects the CSS we use for styling our emails `css-inline`) injects the CSS we use for styling our emails
(`templates/zerver/emails/email.css`) into the templates inline. (`templates/zerver/emails/email.css`) into the templates inline.
What this means is that when you're editing emails, **you need to run What this means is that when you're editing emails, **you need to run

View File

@ -56,7 +56,7 @@ html2text
https://github.com/zulip/talon/archive/137ea31ca506069f9a8bbddde0d0174f395a6893.zip#egg=talon-core==1.6.0.zulip1&subdirectory=talon-core https://github.com/zulip/talon/archive/137ea31ca506069f9a8bbddde0d0174f395a6893.zip#egg=talon-core==1.6.0.zulip1&subdirectory=talon-core
# Needed for inlining the CSS in emails # Needed for inlining the CSS in emails
premailer css-inline
# Needed for JWT-based auth # Needed for JWT-based auth
PyJWT PyJWT

View File

@ -155,10 +155,6 @@ bracex==2.3.post1 \
--hash=sha256:351b7f20d56fb9ea91f9b9e9e7664db466eb234188c175fd943f8f755c807e73 \ --hash=sha256:351b7f20d56fb9ea91f9b9e9e7664db466eb234188c175fd943f8f755c807e73 \
--hash=sha256:e7b23fc8b2cd06d3dec0692baabecb249dda94e06a617901ff03a6c56fd71693 --hash=sha256:e7b23fc8b2cd06d3dec0692baabecb249dda94e06a617901ff03a6c56fd71693
# via wcmatch # via wcmatch
cachetools==5.3.0 \
--hash=sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14 \
--hash=sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4
# via premailer
cairocffi==1.4.0 \ cairocffi==1.4.0 \
--hash=sha256:509339b32ccd8d7b00c2204c32736cde78db53a32e6a162d312478d25626cd9a --hash=sha256:509339b32ccd8d7b00c2204c32736cde78db53a32e6a162d312478d25626cd9a
# via cairosvg # via cairosvg
@ -474,22 +470,40 @@ cryptography==39.0.2 \
# social-auth-core # social-auth-core
# types-pyopenssl # types-pyopenssl
# types-redis # types-redis
css-inline==0.8.7 \
--hash=sha256:0e87293717865bca00bbc2046864e33a421468129a8a8fe938abf2397c4a8e26 \
--hash=sha256:20ee0894e7a72434750799fced0c7404fed19f3d0538c7fb3ff61d4efacd473d \
--hash=sha256:2bf6053352d053d32a2294510416c704e5b62325a8214dca5c764455e318948c \
--hash=sha256:3604d7af3df90681a5a31d2a1438bd237ae1ba171f2ea1cb62824f4909238a63 \
--hash=sha256:36a392ed87f840621838e63937e151d236a62cfdba2760f503273330691ea3d8 \
--hash=sha256:39a90bd53272ee68a4d7909dc9b03240b296c50249b964cb253faf361c90e9dd \
--hash=sha256:51cc996cfce5fd10aa5e41569c664cded198f30f4706e699e97942893aa9e7f9 \
--hash=sha256:5b08837acd1fe60a8f8cbd44dfce88dda1676aca47eeb51bf512c02e90803b77 \
--hash=sha256:6579c9ea5e288e644bd78dbfd3d2bae9c33eb11e3f02d2c61cf4b5683c8f97da \
--hash=sha256:6803c2fb2064330de1761a44ae86ab6c9b78d1761c36ebdd8919346e595d702d \
--hash=sha256:68dac7010c27624627f7df9be12888b9129fd658804f52f8feac25f7d4766050 \
--hash=sha256:6c0f02ddc5b694520d0fb8db7961e703120a373e516a74cfa6c8303b1c131e42 \
--hash=sha256:6e0afb35e17888b2ddd8efee738f1f68ae569615cb32b66427381372cab2d6b2 \
--hash=sha256:773a150ba085d73ea8a4f27f562dcf90a7bc8dcecf0d1867b660f22d036c6a6a \
--hash=sha256:7e5d5aebf87bcacc6bd3f548e0648f4e1b26d4b50e7239df234a42cb1374c039 \
--hash=sha256:819bf4c331e59da07d824529027c00ec618c2f0b1d498f43e5281988339ac082 \
--hash=sha256:84e2b2c5c7c16b5ff546f9fae53e7f0d24bc63e9de6a2a655809c3698ad9f9e2 \
--hash=sha256:c5910202e7583f0d1b6aea34d63cd378e28808f8bcf210a9c961cb26ccc5ed25 \
--hash=sha256:ea7d4313eed1de7def6d2e9c4780f11e1faed3800f2e2ad4de47dc15ba7a40da \
--hash=sha256:f0757b71ae23d4131d7ca274bb35d41f5faa41b88eed9090df3ab409689d055d \
--hash=sha256:f3b5dc09f6de78fa9f7152e701fdfa2ed827a7c0543a6746ba9879e8731c6da5
# via -r requirements/common.in
cssselect==1.2.0 \ cssselect==1.2.0 \
--hash=sha256:666b19839cfaddb9ce9d36bfe4c969132c647b92fc9088c4e23f786b30f1b3dc \ --hash=sha256:666b19839cfaddb9ce9d36bfe4c969132c647b92fc9088c4e23f786b30f1b3dc \
--hash=sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e --hash=sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e
# via # via
# parsel # parsel
# premailer
# scrapy # scrapy
# talon-core # talon-core
cssselect2==0.7.0 \ cssselect2==0.7.0 \
--hash=sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a \ --hash=sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a \
--hash=sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969 --hash=sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969
# via cairosvg # via cairosvg
cssutils==2.6.0 \
--hash=sha256:30c72f3a5c5951a11151640600aae7b3bf10e4c0d5c87f5bc505c2cd4a26e0c2 \
--hash=sha256:f7dcd23c1cec909fdf3630de346e1413b7b2555936dec14ba2ebb9913bf0818e
# via premailer
dataclasses-json==0.5.7 \ dataclasses-json==0.5.7 \
--hash=sha256:bc285b5f892094c3a53d558858a88553dd6a61a11ab1a8128a0e554385dcc5dd \ --hash=sha256:bc285b5f892094c3a53d558858a88553dd6a61a11ab1a8128a0e554385dcc5dd \
--hash=sha256:c2c11bc8214fbf709ffc369d11446ff6945254a7f09128154a7620613d8fda90 --hash=sha256:c2c11bc8214fbf709ffc369d11446ff6945254a7f09128154a7620613d8fda90
@ -1063,7 +1077,6 @@ lxml==4.6.5 \
# via # via
# -r requirements/common.in # -r requirements/common.in
# parsel # parsel
# premailer
# pyoembed # pyoembed
# python3-saml # python3-saml
# scrapy # scrapy
@ -1463,10 +1476,6 @@ polib==1.2.0 \
--hash=sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d \ --hash=sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d \
--hash=sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b --hash=sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b
# via -r requirements/common.in # via -r requirements/common.in
premailer==3.10.0 \
--hash=sha256:021b8196364d7df96d04f9ade51b794d0b77bcc19e998321c515633a2273be1a \
--hash=sha256:d1875a8411f5dc92b53ef9f193db6c0f879dc378d618e0ad292723e388bfe4c2
# via -r requirements/common.in
prompt-toolkit==3.0.38 \ prompt-toolkit==3.0.38 \
--hash=sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b \ --hash=sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b \
--hash=sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f --hash=sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f
@ -1948,7 +1957,6 @@ requests[security]==2.28.2 \
# -r requirements/common.in # -r requirements/common.in
# matrix-client # matrix-client
# moto # moto
# premailer
# pyoembed # pyoembed
# python-digitalocean # python-digitalocean
# python-gcm # python-gcm

View File

@ -84,10 +84,6 @@ botocore==1.29.84 \
# via # via
# boto3 # boto3
# s3transfer # s3transfer
cachetools==5.3.0 \
--hash=sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14 \
--hash=sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4
# via premailer
cchardet==2.1.7 \ cchardet==2.1.7 \
--hash=sha256:0b859069bbb9d27c78a2c9eb997e6f4b738db2d7039a03f8792b4058d61d1109 \ --hash=sha256:0b859069bbb9d27c78a2c9eb997e6f4b738db2d7039a03f8792b4058d61d1109 \
--hash=sha256:228d2533987c450f39acf7548f474dd6814c446e9d6bd228e8f1d9a2d210f10b \ --hash=sha256:228d2533987c450f39acf7548f474dd6814c446e9d6bd228e8f1d9a2d210f10b \
@ -322,16 +318,33 @@ cryptography==39.0.2 \
# -r requirements/common.in # -r requirements/common.in
# pyopenssl # pyopenssl
# social-auth-core # social-auth-core
css-inline==0.8.7 \
--hash=sha256:0e87293717865bca00bbc2046864e33a421468129a8a8fe938abf2397c4a8e26 \
--hash=sha256:20ee0894e7a72434750799fced0c7404fed19f3d0538c7fb3ff61d4efacd473d \
--hash=sha256:2bf6053352d053d32a2294510416c704e5b62325a8214dca5c764455e318948c \
--hash=sha256:3604d7af3df90681a5a31d2a1438bd237ae1ba171f2ea1cb62824f4909238a63 \
--hash=sha256:36a392ed87f840621838e63937e151d236a62cfdba2760f503273330691ea3d8 \
--hash=sha256:39a90bd53272ee68a4d7909dc9b03240b296c50249b964cb253faf361c90e9dd \
--hash=sha256:51cc996cfce5fd10aa5e41569c664cded198f30f4706e699e97942893aa9e7f9 \
--hash=sha256:5b08837acd1fe60a8f8cbd44dfce88dda1676aca47eeb51bf512c02e90803b77 \
--hash=sha256:6579c9ea5e288e644bd78dbfd3d2bae9c33eb11e3f02d2c61cf4b5683c8f97da \
--hash=sha256:6803c2fb2064330de1761a44ae86ab6c9b78d1761c36ebdd8919346e595d702d \
--hash=sha256:68dac7010c27624627f7df9be12888b9129fd658804f52f8feac25f7d4766050 \
--hash=sha256:6c0f02ddc5b694520d0fb8db7961e703120a373e516a74cfa6c8303b1c131e42 \
--hash=sha256:6e0afb35e17888b2ddd8efee738f1f68ae569615cb32b66427381372cab2d6b2 \
--hash=sha256:773a150ba085d73ea8a4f27f562dcf90a7bc8dcecf0d1867b660f22d036c6a6a \
--hash=sha256:7e5d5aebf87bcacc6bd3f548e0648f4e1b26d4b50e7239df234a42cb1374c039 \
--hash=sha256:819bf4c331e59da07d824529027c00ec618c2f0b1d498f43e5281988339ac082 \
--hash=sha256:84e2b2c5c7c16b5ff546f9fae53e7f0d24bc63e9de6a2a655809c3698ad9f9e2 \
--hash=sha256:c5910202e7583f0d1b6aea34d63cd378e28808f8bcf210a9c961cb26ccc5ed25 \
--hash=sha256:ea7d4313eed1de7def6d2e9c4780f11e1faed3800f2e2ad4de47dc15ba7a40da \
--hash=sha256:f0757b71ae23d4131d7ca274bb35d41f5faa41b88eed9090df3ab409689d055d \
--hash=sha256:f3b5dc09f6de78fa9f7152e701fdfa2ed827a7c0543a6746ba9879e8731c6da5
# via -r requirements/common.in
cssselect==1.2.0 \ cssselect==1.2.0 \
--hash=sha256:666b19839cfaddb9ce9d36bfe4c969132c647b92fc9088c4e23f786b30f1b3dc \ --hash=sha256:666b19839cfaddb9ce9d36bfe4c969132c647b92fc9088c4e23f786b30f1b3dc \
--hash=sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e --hash=sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e
# via # via talon-core
# premailer
# talon-core
cssutils==2.6.0 \
--hash=sha256:30c72f3a5c5951a11151640600aae7b3bf10e4c0d5c87f5bc505c2cd4a26e0c2 \
--hash=sha256:f7dcd23c1cec909fdf3630de346e1413b7b2555936dec14ba2ebb9913bf0818e
# via premailer
decorator==5.1.1 \ decorator==5.1.1 \
--hash=sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330 \ --hash=sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330 \
--hash=sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186 --hash=sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186
@ -747,7 +760,6 @@ lxml==4.6.5 \
--hash=sha256:ff44de36772b05c2eb74f2b4b6d1ae29b8f41ed5506310ce1258d44826ee38c1 --hash=sha256:ff44de36772b05c2eb74f2b4b6d1ae29b8f41ed5506310ce1258d44826ee38c1
# via # via
# -r requirements/common.in # -r requirements/common.in
# premailer
# pyoembed # pyoembed
# python3-saml # python3-saml
# social-auth-core # social-auth-core
@ -1022,10 +1034,6 @@ polib==1.2.0 \
--hash=sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d \ --hash=sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d \
--hash=sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b --hash=sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b
# via -r requirements/common.in # via -r requirements/common.in
premailer==3.10.0 \
--hash=sha256:021b8196364d7df96d04f9ade51b794d0b77bcc19e998321c515633a2273be1a \
--hash=sha256:d1875a8411f5dc92b53ef9f193db6c0f879dc378d618e0ad292723e388bfe4c2
# via -r requirements/common.in
prompt-toolkit==3.0.38 \ prompt-toolkit==3.0.38 \
--hash=sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b \ --hash=sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b \
--hash=sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f --hash=sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f
@ -1425,7 +1433,6 @@ requests[security]==2.28.2 \
# via # via
# -r requirements/common.in # -r requirements/common.in
# matrix-client # matrix-client
# premailer
# pyoembed # pyoembed
# python-gcm # python-gcm
# python-twitter # python-twitter

View File

@ -2,36 +2,20 @@
import os import os
from typing import Set from typing import Set
from cssutils import profile import css_inline
from cssutils.profiles import Profiles, macros, properties
from premailer import Premailer
ZULIP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../") ZULIP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../")
EMAIL_TEMPLATES_PATH = os.path.join(ZULIP_PATH, "templates", "zerver", "emails") EMAIL_TEMPLATES_PATH = os.path.join(ZULIP_PATH, "templates", "zerver", "emails")
CSS_SOURCE_PATH = os.path.join(EMAIL_TEMPLATES_PATH, "email.css") CSS_SOURCE_PATH = os.path.join(EMAIL_TEMPLATES_PATH, "email.css")
def configure_cssutils() -> None: def get_inliner_instance() -> css_inline.CSSInliner:
# These properties are not supported by cssutils by default and will with open(CSS_SOURCE_PATH) as file:
# result in warnings when premailer package is run. content = file.read()
properties[Profiles.CSS_LEVEL_2]["-ms-interpolation-mode"] = r"none|bicubic|nearest-neighbor" return css_inline.CSSInliner(extra_css=content)
properties[Profiles.CSS_LEVEL_2]["-ms-text-size-adjust"] = r"none|auto|{percentage}"
properties[Profiles.CSS_LEVEL_2]["mso-table-lspace"] = r"0|{num}(pt)"
properties[Profiles.CSS_LEVEL_2]["mso-table-rspace"] = r"0|{num}(pt)"
properties[Profiles.CSS_LEVEL_2]["-webkit-text-size-adjust"] = r"none|auto|{percentage}"
properties[Profiles.CSS_LEVEL_2]["mso-hide"] = "all"
properties[Profiles.CSS_LEVEL_2]["pointer-events"] = (
r"auto|none|visiblePainted|"
r"visibleFill|visibleStroke|"
r"visible|painted|fill|stroke|all|inherit"
)
profile.addProfiles(
[(Profiles.CSS_LEVEL_2, properties[Profiles.CSS_LEVEL_2], macros[Profiles.CSS_LEVEL_2])]
)
configure_cssutils() inliner = get_inliner_instance()
def inline_template(template_source_name: str) -> None: def inline_template(template_source_name: str) -> None:
@ -45,13 +29,11 @@ def inline_template(template_source_name: str) -> None:
with open(template_path) as template_source_file: with open(template_path) as template_source_file:
template_str = template_source_file.read() template_str = template_source_file.read()
output = Premailer( output = inliner.inline(template_str)
template_str, external_styles=[CSS_SOURCE_PATH], allow_loading_external_files=True
).transform()
output = escape_jinja2_characters(output) output = escape_jinja2_characters(output)
# Premailer.transform will try to complete the DOM tree, # Inline method of css-inline will try to complete the DOM tree,
# adding html, head, and body tags if they aren't there. # adding html, head, and body tags if they aren't there.
# While this is correct for the email_base_default template, # While this is correct for the email_base_default template,
# it is wrong for the other templates that extend this # it is wrong for the other templates that extend this
@ -83,7 +65,7 @@ def escape_jinja2_characters(text: str) -> str:
def strip_unnecessary_tags(text: str) -> str: def strip_unnecessary_tags(text: str) -> str:
end_block = "</body>\n</html>" end_block = "</body></html>"
start_block = "{% extends" start_block = "{% extends"
start = text.find(start_block) start = text.find(start_block)
end = text.rfind(end_block) end = text.rfind(end_block)

View File

@ -673,7 +673,7 @@ html_rules: List["Rule"] = [
"web/templates/single_message.hbs", "web/templates/single_message.hbs",
# Old-style email templates need to use inline style # Old-style email templates need to use inline style
# attributes; it should be possible to clean these up # attributes; it should be possible to clean these up
# when we convert these templates to use premailer. # when we convert these templates to use css-inline.
"templates/zerver/emails/email_base_messages.html", "templates/zerver/emails/email_base_messages.html",
# Email log templates; should clean up. # Email log templates; should clean up.
"templates/zerver/email.html", "templates/zerver/email.html",

View File

@ -48,4 +48,4 @@ API_FEATURE_LEVEL = 169
# historical commits sharing the same major version, in which case a # historical commits sharing the same major version, in which case a
# minor version bump suffices. # minor version bump suffices.
PROVISION_VERSION = (227, 1) PROVISION_VERSION = (228, 0)

View File

@ -987,7 +987,7 @@ earl-test@zulip.com""",
# field, because we've included that field inside the mailto: # field, because we've included that field inside the mailto:
# link for the sender. # link for the sender.
self.assertIn( self.assertIn(
'<a href="mailto:hamlet@zulip.com" style="color:#5f5ec7; text-decoration:underline">&lt;/a&gt; https://www.google.com (hamlet@zulip.com)</a> wants', '<a href="mailto:hamlet@zulip.com" style="color: #5f5ec7;text-decoration: underline;">&lt;/a&gt; https://www.google.com (hamlet@zulip.com)</a> wants',
body, body,
) )