discourse/spec/lib/email/styles_spec.rb
Isaac Janzen 692e0140e2
FEATURE: Enables support for dark mode emails (#16520)
This PR enables custom email dark mode styles by default that were added here.

There is currently poor support for dark mode queries in mail clients. The main beneficiary of these changes will be Apple Mail and Outlook.

Enjoy the darkness 🕶️
2022-04-20 13:00:04 -05:00

438 lines
18 KiB
Ruby

# frozen_string_literal: true
require 'email'
describe Email::Styles do
let(:attachments) { {} }
def basic_fragment(html)
styler = Email::Styles.new(html)
styler.format_basic
Nokogiri::HTML5.fragment(styler.to_html)
end
def html_fragment(html)
styler = Email::Styles.new(html)
styler.format_basic
styler.format_html
Nokogiri::HTML5.fragment(styler.to_html)
end
context "basic formatter" do
it "adds a max-width to large images" do
frag = basic_fragment("<img height='auto' width='auto' src='gigantic.jpg'>")
expect(frag.at("img")["style"]).to match("max-width")
end
it "adds a width and height to emojis" do
frag = basic_fragment("<img src='/images/emoji/fish.png' class='emoji'>")
expect(frag.at("img")["width"]).to eq("20")
expect(frag.at("img")["height"]).to eq("20")
end
it "adds a width and height to custom emojis" do
frag = basic_fragment("<img src='/uploads/default/_emoji/fish.png' class='emoji emoji-custom'>")
expect(frag.at("img")["width"]).to eq("20")
expect(frag.at("img")["height"]).to eq("20")
end
it "converts relative paths to absolute paths" do
frag = basic_fragment("<img src='/some-image.png'>")
expect(frag.at("img")["src"]).to eq("#{Discourse.base_url}/some-image.png")
end
it "strips classes and ids" do
frag = basic_fragment("<div class='foo' id='bar'><div class='foo' id='bar'></div></div>")
expect(frag.to_html).to eq("<div><div></div></div>")
end
end
context "html template formatter" do
it "attaches a style to h3 tags" do
frag = html_fragment("<h3>hello</h3>")
expect(frag.at('h3')['style']).to be_present
end
it "attaches a style to hr tags" do
frag = html_fragment("hello<hr>")
expect(frag.at('hr')['style']).to be_present
end
it "attaches a style to a tags" do
frag = html_fragment("<a href>wat</a>")
expect(frag.at('a')['style']).to be_present
end
it "attaches a style to a tags" do
frag = html_fragment("<a href>wat</a>")
expect(frag.at('a')['style']).to be_present
end
it "attaches a style to ul and li tags" do
frag = html_fragment("<ul><li>hello</li></ul>")
expect(frag.at('ul')['style']).to be_present
expect(frag.at('li')['style']).to be_present
end
it "converts iframes to links" do
iframe_url = "http://www.youtube.com/embed/7twifrxOTQY?feature=oembed&wmode=opaque"
frag = html_fragment("<iframe src=\"#{iframe_url}\"></iframe>")
expect(frag.at('iframe')).to be_blank
expect(frag.at('a')).to be_present
expect(frag.at('a')['href']).to eq(iframe_url)
end
it "won't allow non URLs in iframe src, strips them with no link" do
iframe_url = "alert('xss hole')"
frag = html_fragment("<iframe src=\"#{iframe_url}\"></iframe>")
expect(frag.at('iframe')).to be_blank
expect(frag.at('a')).to be_blank
end
it "won't allow empty iframe src, strips them with no link" do
frag = html_fragment("<iframe src=''></iframe>")
expect(frag.at('iframe')).to be_blank
expect(frag.at('a')).to be_blank
end
it "prefers data-original-href attribute to get iframe link" do
original_url = "https://vimeo.com/329875646/85f1546a42"
iframe_url = "https://player.vimeo.com/video/329875646"
frag = html_fragment("<iframe src=\"#{iframe_url}\" data-original-href=\"#{original_url}\"></iframe>")
expect(frag.at('iframe')).to be_blank
expect(frag.at('a')).to be_present
expect(frag.at('a')['href']).to eq(original_url)
end
end
context "rewriting protocol relative URLs to the forum" do
it "doesn't rewrite a url to another site" do
frag = html_fragment('<a href="//youtube.com/discourse">hello</a>')
expect(frag.at('a')['href']).to eq("//youtube.com/discourse")
end
context "without https" do
before do
SiteSetting.force_https = false
end
it "rewrites the href to have http" do
frag = html_fragment('<a href="//test.localhost/discourse">hello</a>')
expect(frag.at('a')['href']).to eq("http://test.localhost/discourse")
end
it "rewrites the href for attachment files to have http" do
frag = html_fragment('<a class="attachment" href="//try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt">attachment_file.txt</a>')
expect(frag.at('a')['href']).to eq("http://try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt")
end
it "rewrites the src to have http" do
frag = html_fragment('<img src="//test.localhost/blah.jpg">')
expect(frag.at('img')['src']).to eq("http://test.localhost/blah.jpg")
end
end
context "with https" do
before do
SiteSetting.force_https = true
end
it "rewrites the forum URL to have https" do
frag = html_fragment('<a href="//test.localhost/discourse">hello</a>')
expect(frag.at('a')['href']).to eq("https://test.localhost/discourse")
end
it "rewrites the href for attachment files to have https" do
frag = html_fragment('<a class="attachment" href="//try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt">attachment_file.txt</a>')
expect(frag.at('a')['href']).to eq("https://try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt")
end
it "rewrites the src to have https" do
frag = html_fragment('<img src="//test.localhost/blah.jpg">')
expect(frag.at('img')['src']).to eq("https://test.localhost/blah.jpg")
end
end
end
context "dark mode emails" do
it "adds dark_mode_styles when site setting active" do
frag = html_fragment('<div class="body">test</div>')
styler = Email::Styles.new(frag)
styler.format_basic
styler.format_html
@frag = Nokogiri::HTML5.fragment(styler.to_s)
# dark mode attribute
expect(@frag.css('[dm="body"]')).to be_present
end
end
context "strip_avatars_and_emojis" do
it "works for lonesome emoji with no title" do
emoji = "<img src='/images/emoji/twitter/crying_cat_face.png'>"
style = Email::Styles.new(emoji)
style.strip_avatars_and_emojis
expect(style.to_html).to match_html(emoji)
end
it "works for lonesome emoji with title" do
emoji = "<img title='cry_cry' src='/images/emoji/twitter/crying_cat_face.png'>"
style = Email::Styles.new(emoji)
style.strip_avatars_and_emojis
expect(style.to_html).to match_html("cry_cry")
end
it "works if img tag has no attrs" do
cooked = "Create a method for click on image and use ng-click in <img> in your slide box...it is simple"
style = Email::Styles.new(cooked)
style.strip_avatars_and_emojis
expect(style.to_html).to include(cooked)
end
end
context "onebox_styles" do
it "renders quote as <blockquote>" do
fragment = html_fragment('<aside class="quote"> <div class="title"> <div class="quote-controls"> <i class="fa fa-chevron-down" title="expand/collapse"></i><a href="/t/xyz/123" title="go to the quoted post" class="back"></a> </div> <img alt="" width="20" height="20" src="https://cdn-enterprise.discourse.org/boingboing/user_avatar/bbs.boingboing.net/techapj/40/54379_1.png" class="avatar">techAPJ: </div> <blockquote> <p>lorem ipsum</p> </blockquote> </aside>')
expect(fragment.to_s.squish).to match(/^<blockquote.+<\/blockquote>$/)
end
it "removes GitHub excerpts" do
stub_request(:head, "https://github.com/discourse/discourse/pull/1253").to_return(status: 200, body: "", headers: {})
stub_request(:get, "https://api.github.com/repos/discourse/discourse/pulls/1253").to_return(status: 200, body: onebox_response("githubpullrequest"))
onebox = Oneboxer.onebox("https://github.com/discourse/discourse/pull/1253")
fragment = html_fragment(onebox)
expect(fragment.css(".github-body-container .excerpt")).to be_empty
end
end
context "replace_secure_media_urls" do
before do
setup_s3
SiteSetting.secure_media = true
end
let(:attachments) { { 'testimage.png' => stub(url: 'email/test.png') } }
it "replaces secure media within a link with a placeholder" do
frag = html_fragment("<a href=\"#{Discourse.base_url}\/secure-media-uploads/original/1X/testimage.png\"><img src=\"/secure-media-uploads/original/1X/testimage.png\"></a>")
expect(frag.at('img')).not_to be_present
expect(frag.to_s).to include("Redacted")
end
it "replaces secure images with a placeholder" do
frag = html_fragment("<img src=\"/secure-media-uploads/original/1X/testimage.png\">")
expect(frag.at('img')).not_to be_present
expect(frag.to_s).to include("Redacted")
end
it "does not replace topic links with secure-media-uploads in the name" do
frag = html_fragment("<a href=\"#{Discourse.base_url}\/t/secure-media-uploads/235723\">Visit Topic</a>")
expect(frag.to_s).not_to include("Redacted")
end
it "works in lightboxes with missing srcset attribute" do
frag = html_fragment("<a href=\"#{Discourse.base_url}\/secure-media-uploads/original/1X/testimage.png\" class=\"lightbox\"><img src=\"/secure-media-uploads/original/1X/testimage.png\"></a>")
expect(frag.at('img')).not_to be_present
expect(frag.to_s).to include("Redacted")
end
it "works in lightboxes with srcset attribute set" do
frag = html_fragment(
<<~HTML
<a href="#{Discourse.base_url}/secure-media-uploads/original/1X/testimage.png" class="lightbox">
<img src="/secure-media-uploads/original/1X/testimage.png" srcset="/secure-media-uploads/optimized/1X/testimage.png, /secure-media-uploads/original/1X/testimage.png 1.5x" />
</a>
HTML
)
expect(frag.at('img')).not_to be_present
expect(frag.to_s).to include("Redacted")
end
it "skips links with no images as children" do
frag = html_fragment("<a href=\"#{Discourse.base_url}\/secure-media-uploads/original/1X/testimage.png\"><span>Clearly not an image</span></a>")
expect(frag.to_s).to include("not an image")
end
end
context "inline_secure_images" do
before do
setup_s3
SiteSetting.secure_media = true
end
fab!(:upload) { Fabricate(:upload, original_filename: 'testimage.png', secure: true, sha1: '123456') }
let(:attachments) { [stub(url: 'cid:email/test.png')] }
let(:attachments_index) { { upload.sha1 => 0 } }
let(:html) { "<a href=\"#{Discourse.base_url}\/secure-media-uploads/original/1X/123456.png\"><img src=\"/secure-media-uploads/original/1X/123456.png\" width=\"20\" height=\"30\"></a>" }
def strip_and_inline
# strip out the secure media
styler = Email::Styles.new(html)
styler.format_basic
styler.format_html
html = styler.to_html
# pass in the attachments to match uploads based on sha + original filename
styler = Email::Styles.new(html)
styler.inline_secure_images(attachments, attachments_index)
@frag = Nokogiri::HTML5.fragment(styler.to_s)
end
it "inlines attachments where stripped-secure-media data attr is present" do
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.css('[data-stripped-secure-media]')).not_to be_present
expect(@frag.children.attr('style').value).to eq("width: 20px; height: 30px;")
end
it "does not inline anything if the upload cannot be found" do
upload.update(sha1: 'blah12')
strip_and_inline
expect(@frag.to_s).not_to include("cid:email/test.png")
expect(@frag.css('[data-stripped-secure-media]')).to be_present
end
context "when an optimized image is used instead of the original" do
let(:html) { "<a href=\"#{Discourse.base_url}\/secure-media-uploads/optimized/2X/1/123456_2_20x30.png\"><img src=\"/secure-media-uploads/optimized/2X/1/123456_2_20x30.png\" width=\"20\" height=\"30\"></a>" }
it "inlines attachments where the stripped-secure-media data attr is present" do
optimized = Fabricate(:optimized_image, upload: upload, width: 20, height: 30)
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.css('[data-stripped-secure-media]')).not_to be_present
expect(@frag.children.attr('style').value).to eq("width: 20px; height: 30px;")
end
end
context "when inlining an originally oneboxed image" do
before do
SiteSetting.authorized_extensions = "*"
end
let(:siteicon) { Fabricate(:upload, original_filename: "siteicon.ico") }
let(:attachments) { [stub(url: 'cid:email/test.png'), stub(url: 'cid:email/test2.ico')] }
let(:attachments_index) { { upload.sha1 => 0, siteicon.sha1 => 1 } }
let(:html) do
<<~HTML
<aside class="onebox allowlistedgeneric">
<header class="source">
<img src="#{Discourse.base_url}/secure-media-uploads/original/1X/#{siteicon.sha1}.ico" class="site-icon" width="64" height="64">
<a href="https://test.com/article" target="_blank" rel="noopener" title="02:33PM - 24 October 2020">Test</a>
</header>
<article class="onebox-body">
<div class="aspect-image" style="--aspect-ratio:20/30;"><img src="#{Discourse.base_url}/secure-media-uploads/optimized/2X/1/123456_2_20x30.png" class="thumbnail d-lazyload" width="20" height="30" srcset="#{Discourse.base_url}/secure-media-uploads/optimized/2X/1/123456_2_20x30.png"></div>
<h3><a href="https://test.com/article" target="_blank" rel="noopener">Test</a></h3>
<p>This is a test onebox.</p>
</article>
<div class="onebox-metadata">
</div>
<div style="clear: both"></div>
</aside>
HTML
end
it "keeps the special site icon width and height and onebox styles" do
optimized = Fabricate(:optimized_image, upload: upload, width: 20, height: 30)
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.to_s).to include("cid:email/test2.ico")
expect(@frag.css('[data-stripped-secure-media]')).not_to be_present
expect(@frag.css('[data-embedded-secure-image]')[0].attr('style')).to eq('width: 16px; height: 16px;')
expect(@frag.css('[data-embedded-secure-image]')[1].attr('style')).to eq('width: 60px; max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;')
end
context "when inlining a oneboxed image with a direct parent of onebox-body" do
let(:html) do
<<~HTML
<aside class="onebox allowlistedgeneric">
<header class="source">
<img src="#{Discourse.base_url}/secure-media-uploads/original/1X/#{siteicon.sha1}.ico" class="site-icon" width="64" height="64">
<a href="https://test.com/article" target="_blank" rel="noopener" title="02:33PM - 24 October 2020">Test</a>
</header>
<article class="onebox-body">
<img src="#{Discourse.base_url}/secure-media-uploads/original/1X/123456.png" class="thumbnail onebox-avatar" width="20" height="30">
<h3><a href="https://test.com/article" target="_blank" rel="noopener">Test</a></h3>
<p>This is a test onebox.</p>
</article>
<div class="onebox-metadata">
</div>
<div style="clear: both"></div>
</aside>
HTML
end
it "keeps the special onebox styles" do
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.to_s).to include("cid:email/test2.ico")
expect(@frag.css('[data-stripped-secure-media]')).not_to be_present
expect(@frag.css('[data-embedded-secure-image]')[1].attr('style')).to eq('width: 60px; max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;')
end
end
context "when there is an inline-avatar in the onebox" do
let(:html) do
<<~HTML
<p><a class="mention" href="/u/martin">@martin</a> check this out:</p>
<aside class="onebox githubpullrequest">
<header class="source">
<a href="https://github.com/discourse/discourse/pull/11140" target="_blank" rel="noopener">github.com/discourse/discourse</a>
</header>
<article class="onebox-body">
<div class="github-row">
<div class="github-info-container">
<h4>
<a href="https://github.com/discourse/discourse/pull/11140" target="_blank" rel="noopener">FEATURE: Implement edit functionality for post notices</a>
</h4>
<div class="branches">
<code>discourse:master</code> ← <code>discourse:feature/post_notices_edit</code>
</div>
<div class="github-info">
<div class="date">
opened <span class="discourse-local-date" data-format="ll" data-date="2020-11-05" data-time="20:33:53" data-timezone="UTC">08:33PM - 05 Nov 20 UTC</span>
</div>
<div class="user">
<a href="https://github.com/udan11" target="_blank" rel="noopener">
<img alt="udan11" src="#{Discourse.base_url}/secure-media-uploads/original/1X/123456.png" class="onebox-avatar-inline" width="20" height="20">
udan11
</a>
</div>
<div class="lines" title="2 commits changed 27 files with 250 additions and 224 deletions">
<a href="https://github.com/discourse/discourse/pull/11140/files" target="_blank" rel="noopener">
<span class="added">+250</span>
<span class="removed">-224</span>
</a>
</div>
</div>
</div>
</div>
</article>
<div class="onebox-metadata">
</div>
<div style="clear: both"></div>
</aside>
HTML
end
it "keeps the special onebox styles" do
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.css('[data-stripped-secure-media]')).not_to be_present
expect(@frag.css('[data-embedded-secure-image]')[0].attr('style')).to eq('width: 20px; height: 20px; float: none; vertical-align: middle; max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;')
end
end
end
end
end