Class: Email::Styles

Inherits:
Object
  • Object
show all
Defined in:
lib/email/styles.rb

Constant Summary collapse

MAX_IMAGE_DIMENSION =
400
ONEBOX_IMAGE_BASE_STYLE =
"max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;"
ONEBOX_IMAGE_THUMBNAIL_STYLE =
"width: 60px;"
ONEBOX_INLINE_AVATAR_STYLE =
"width: 20px; height: 20px; float: none; vertical-align: middle;"
@@plugin_callbacks =
[]

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(html, opts = nil) ⇒ Styles

Returns a new instance of Styles.



21
22
23
24
25
26
# File 'lib/email/styles.rb', line 21

def initialize(html, opts = nil)
  @html = html
  @opts = opts || {}
  @fragment = Nokogiri::HTML5.parse(@html)
  @custom_styles = nil
end

Instance Attribute Details

#fragmentObject

Returns the value of attribute fragment.



17
18
19
# File 'lib/email/styles.rb', line 17

def fragment
  @fragment
end

Class Method Details

.register_plugin_style(&block) ⇒ Object



28
29
30
# File 'lib/email/styles.rb', line 28

def self.register_plugin_style(&block)
  @@plugin_callbacks.push(block)
end

Instance Method Details

#add_styles(node, new_styles) ⇒ Object



32
33
34
35
36
37
38
39
40
# File 'lib/email/styles.rb', line 32

def add_styles(node, new_styles)
  existing = node["style"]
  if existing.present?
    # merge styles
    node["style"] = "#{new_styles}; #{existing}"
  else
    node["style"] = new_styles
  end
end

#custom_stylesObject



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/email/styles.rb', line 42

def custom_styles
  return @custom_styles unless @custom_styles.nil?

  css = EmailStyle.new.compiled_css
  @custom_styles = {}

  if !css.blank?
    # there is a minor race condition here, CssParser could be
    # loaded by ::CssParser::Parser not loaded
    require "css_parser" unless defined?(::CssParser::Parser)

    parser = ::CssParser::Parser.new(import: false)
    parser.load_string!(css)
    parser.each_selector do |selector, value|
      @custom_styles[selector] ||= +""
      @custom_styles[selector] << value
    end
  end

  @custom_styles
end

#format_basicObject



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/email/styles.rb', line 64

def format_basic
  uri = URI(Discourse.base_url)

  # Remove SVGs
  @fragment.css('svg, img[src$=".svg"]').remove

  # images
  @fragment
    .css("img")
    .each do |img|
      next if img["class"] == "site-logo"

      if (img["class"] && img["class"]["emoji"]) || (img["src"] && img["src"][%r{/_?emoji/}])
        img["width"] = img["height"] = 20
      else
        # use dimensions of original iPhone screen for 'too big, let device rescale'
        if img["width"].to_i > (320) || img["height"].to_i > (480)
          img["width"] = img["height"] = "auto"
        end
      end

      if img["src"]
        # ensure all urls are absolute
        img["src"] = "#{Discourse.base_url}#{img["src"]}" if img["src"][%r{\A/[^/]}]
        # ensure no schemaless urls
        img["src"] = "#{uri.scheme}:#{img["src"]}" if img["src"][%r{\A//}]
      end
    end

  # add max-width to big images
  big_images =
    @fragment.css('img[width="auto"][height="auto"]') - @fragment.css("aside.onebox img") -
      @fragment.css("img.site-logo, img.emoji")
  big_images.each { |img| add_styles(img, "max-width: 100%;") if img["style"] !~ /max-width/ }

  # topic featured link
  @fragment
    .css("a.topic-featured-link")
    .each do |e|
      e[
        "style"
      ] = "color:#858585;padding:2px 8px;border:1px solid #e6e6e6;border-radius:2px;box-shadow:0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);"
    end

  # attachments
  @fragment
    .css("a.attachment")
    .each do |a|
      # ensure all urls are absolute
      a["href"] = "#{Discourse.base_url}#{a["href"]}" if a["href"] =~ %r{\A/[^/]}

      # ensure no schemaless urls
      a["href"] = "#{uri.scheme}:#{a["href"]}" if a["href"] && a["href"].starts_with?("//")
    end
end

#format_customObject



308
309
310
# File 'lib/email/styles.rb', line 308

def format_custom
  custom_styles.each { |selector, value| style(selector, value) }
end

#format_htmlObject



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/email/styles.rb', line 215

def format_html
  correct_first_body_margin
  correct_footer_style
  correct_footer_style_highlight_first
  strip_hashtag_link_icons
  reset_tables

  html_lang = SiteSetting.default_locale.sub("_", "-")
  style("html", nil, :lang => html_lang, "xml:lang" => html_lang)
  style("body", "line-height: 1.4; text-align:#{Rtl.new(nil).enabled? ? "right" : "left"};")
  style("body", nil, dir: Rtl.new(nil).enabled? ? "rtl" : "ltr")

  style(
    ".with-dir",
    "text-align:#{Rtl.new(nil).enabled? ? "right" : "left"};",
    dir: Rtl.new(nil).enabled? ? "rtl" : "ltr",
  )

  style("blockquote > :first-child", "margin-top: 0;")
  style("blockquote > :last-child", "margin-bottom: 0;")
  style("blockquote > p", "padding: 0;")

  style(
    ".with-accent-colors",
    "background-color: #{SiteSetting.email_accent_bg_color}; color: #{SiteSetting.email_accent_fg_color};",
  )
  style("h4", "color: #222;")
  style("h3", "margin: 30px 0 10px;")
  style("hr", "background-color: #ddd; height: 1px; border: 1px;")
  style(
    "a",
    "text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color};",
  )
  style("ul", "margin: 0 0 0 10px; padding: 0 0 0 20px;")
  style("li", "padding-bottom: 10px")
  style("div.summary-footer", "color:#666; font-size:95%; text-align:center; padding-top:15px;")
  style("span.post-count", "margin: 0 5px; color: #777;")
  style("pre", "word-wrap: break-word; max-width: 694px;")
  style("code", "background-color: #f9f9f9; padding: 2px 5px;")
  style("pre code", "display: block; background-color: #f9f9f9; overflow: auto; padding: 5px;")
  style("pre.onebox code", "white-space: normal;")
  style("pre code li", "white-space: pre;")
  style(
    ".featured-topic a",
    "text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color}; line-height:1.5em;",
  )
  style(
    ".summary-email",
    "-moz-box-sizing:border-box;-ms-text-size-adjust:100%;-webkit-box-sizing:border-box;-webkit-text-size-adjust:100%;box-sizing:border-box;color:#0a0a0a;font-family:Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.3;margin:0;min-width:100%;padding:0;width:100%",
  )

  style(".previous-discussion", "font-size: 17px; color: #444; margin-bottom:10px;")
  style(
    ".notification-date",
    "text-align:right;color:#999999;padding-right:5px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;font-size:11px",
  )
  style(
    ".username",
    "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;font-weight:bold",
  )
  style(".username-link", "color:#{SiteSetting.email_link_color};")
  style(".username-title", "color:#777;margin-left:5px;")
  style(
    ".user-title",
    "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:5px;color: #999;",
  )
  style(".post-wrapper", "margin-bottom:25px;")
  style(".user-avatar", "vertical-align:top;width:55px;")
  style(".user-avatar img", nil, width: "45", height: "45")
  style("hr", "background-color: #ddd; height: 1px; border: 1px;")
  style(".rtl", "direction: rtl;")
  style("div.body", "padding-top:5px;")
  style(".whisper div.body", "font-style: italic; color: #9c9c9c;")
  style(".lightbox-wrapper .meta", "display: none")
  style("div.undecorated-link-footer a", "font-weight: normal;")
  style(
    ".mso-accent-link",
    "mso-border-alt: 6px solid #{SiteSetting.email_accent_bg_color}; background-color: #{SiteSetting.email_accent_bg_color};",
  )
  style(
    ".reply-above-line",
    "font-size: 10px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;color: #b5b5b5;padding: 5px 0px 20px;border-top: 1px dotted #ddd;",
  )

  onebox_styles
  plugin_styles
  dark_mode_styles

  style(".post-excerpt img", "max-width: 50%; max-height: #{MAX_IMAGE_DIMENSION}px;")

  format_custom
end

#inline_secure_images(attachments, attachments_index) ⇒ Object



317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/email/styles.rb', line 317

def inline_secure_images(attachments, attachments_index)
  stripped_media = @fragment.css("[data-stripped-secure-media], [data-stripped-secure-upload]")
  upload_shas = {}
  stripped_media.each do |div|
    url = div["data-stripped-secure-media"] || div["data-stripped-secure-upload"]
    filename = File.basename(url)
    filename_bare = filename.gsub(File.extname(filename), "")
    sha1 = filename_bare.partition("_").first
    upload_shas[url] = sha1
  end
  uploads = Upload.select(:original_filename, :sha1).where(sha1: upload_shas.values)

  stripped_media.each do |div|
    upload =
      uploads.find do |upl|
        upl.sha1 ==
          (
            upload_shas[div["data-stripped-secure-media"]] ||
              upload_shas[div["data-stripped-secure-upload"]]
          )
      end
    next if !upload

    if attachments[attachments_index[upload.sha1]]
      url = attachments[attachments_index[upload.sha1]].url

      onebox_type = div["data-onebox-type"]
      style =
        if onebox_type
          onebox_style =
            (
              if onebox_type == "avatar-inline"
                ONEBOX_INLINE_AVATAR_STYLE
              else
                ONEBOX_IMAGE_THUMBNAIL_STYLE
              end
            )
          "#{onebox_style} #{ONEBOX_IMAGE_BASE_STYLE}"
        else
          calculate_width_and_height_style(div)
        end

      div.add_next_sibling(<<~HTML)
        <img src="#{url}" data-embedded-secure-image="true" style="#{style}" />
      HTML
      div.remove
    end
  end
end


410
411
412
413
414
415
416
417
418
419
420
421
# File 'lib/email/styles.rb', line 410

def make_all_links_absolute
  site_uri = URI(Discourse.base_url)
  @fragment
    .css("a")
    .each do |link|
      begin
        link["href"] = "#{site_uri}#{link["href"]}" unless URI(link["href"].to_s).host.present?
      rescue URI::Error
        # leave it
      end
    end
end

#onebox_stylesObject



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/email/styles.rb', line 120

def onebox_styles
  # Links to other topics
  style("aside.quote", "padding: 12px 25px 2px 12px; margin-bottom: 10px;")
  style("aside.quote div.info-line", "color: #666; margin: 10px 0")
  style(
    "aside.quote .avatar",
    "margin-right: 5px; width:20px; height:20px; vertical-align:middle;",
  )
  style("aside.quote", "border-left: 5px solid #e9e9e9; background-color: #f8f8f8; margin: 0;")

  style(
    "blockquote",
    "border-left: 5px solid #e9e9e9; background-color: #f8f8f8; margin-left: 0; padding: 12px;",
  )

  # Oneboxes
  style(
    "aside.onebox",
    "border: 5px solid #e9e9e9; padding: 12px 25px 12px 12px; margin-bottom: 10px;",
  )
  style("aside.onebox header img.site-icon", "width: 16px; height: 16px; margin-right: 3px;")
  style("aside.onebox header a[href]", "color: #222222; text-decoration: none;")
  style("aside.onebox .onebox-body", "clear: both")
  style("aside.onebox .onebox-body img:not(.onebox-avatar-inline)", ONEBOX_IMAGE_BASE_STYLE)
  style("aside.onebox .onebox-body img.thumbnail", ONEBOX_IMAGE_THUMBNAIL_STYLE)
  style(
    "aside.onebox .onebox-body h3, aside.onebox .onebox-body h4",
    "font-size: 1.17em; margin: 10px 0;",
  )
  style(".onebox-metadata", "color: #919191")
  style(".github-info", "margin-top: 10px;")
  style(".github-info .added", "color: #090;")
  style(".github-info .removed", "color: #e45735;")
  style(".github-info div", "display: inline; margin-right: 10px;")
  style(".github-icon-container", "float: left;")
  style(".github-icon-container *", "fill: #646464; width: 40px; height: 40px;")
  style(
    ".github-body-container",
    'font-family: Consolas, Menlo, Monaco, "Lucida Console", "Liberation Mono", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Courier New", monospace; margin-top: 1em !important;',
  )
  style(".onebox-avatar-inline", ONEBOX_INLINE_AVATAR_STYLE)

  @fragment.css(".github-body-container .excerpt").remove

  @fragment.css("aside.quote blockquote > p").each { |p| p["style"] = "padding: 0;" }

  # Convert all `aside.quote` tags to `blockquote`s
  @fragment
    .css("aside.quote")
    .each do |n|
      original_node = n.dup
      original_node.search("div.quote-controls").remove
      blockquote =
        (
          if original_node.css("blockquote").inner_html.strip.start_with?("<p")
            original_node.css("blockquote").inner_html
          else
            "<p style='padding: 0;'>#{original_node.css("blockquote").inner_html}</p>"
          end
        )
      n.inner_html = original_node.css("div.title").inner_html + blockquote
      n.name = "blockquote"
    end

  # Finally, convert all `aside` tags to `div`s
  @fragment.css("aside, article, header").each { |n| n.name = "div" }

  # iframes can't go in emails, so replace them with clickable links
  @fragment
    .css("iframe")
    .each do |i|
      begin
        # sometimes, iframes are blocklisted...
        if i["src"].blank?
          i.remove
          next
        end

        src_uri =
          i["data-original-href"].present? ? URI(i["data-original-href"]) : URI(i["src"])
        # If an iframe is protocol relative, use SSL when displaying it
        display_src =
          "#{src_uri.scheme || "https"}://#{src_uri.host}#{src_uri.path}#{src_uri.query.nil? ? "" : "?" + src_uri.query}#{src_uri.fragment.nil? ? "" : "#" + src_uri.fragment}"
        i.replace(
          Nokogiri::HTML5.fragment(
            "<p><a href='#{src_uri.to_s}'>#{CGI.escapeHTML(display_src)}</a><p>",
          ),
        )
      rescue URI::Error
        # If the URL is weird, remove the iframe
        i.remove
      end
    end
end

#plugin_stylesObject

this method is reserved for styles specific to plugin



313
314
315
# File 'lib/email/styles.rb', line 313

def plugin_styles
  @@plugin_callbacks.each { |block| block.call(@fragment, @opts) }
end

#strip_avatars_and_emojisObject



381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/email/styles.rb', line 381

def strip_avatars_and_emojis
  @fragment
    .search("img")
    .each do |img|
      next unless img["src"]

      if img["src"][/_avatar/]
        img.parent["style"] = "vertical-align: top;" if img.parent&.name == "td"
        img.remove
      end

      if img["title"] && img["src"][%r{/_?emoji/}]
        img.add_previous_sibling(img["title"] || "emoji")
        img.remove
      end
    end
end


399
400
401
402
403
404
405
406
407
408
# File 'lib/email/styles.rb', line 399

def strip_hashtag_link_icons
  @fragment
    .search(".hashtag-cooked")
    .each do |hashtag|
      hashtag.children.each(&:remove)
      hashtag.add_child(<<~HTML)
      <span>##{hashtag["data-slug"]}</span>
    HTML
    end
end

#to_htmlObject



367
368
369
370
371
372
373
374
375
# File 'lib/email/styles.rb', line 367

def to_html
  # needs to be before class + id strip because we need to style redacted
  # media and also not double-redact already redacted from lower levels
  replace_secure_uploads_urls if SiteSetting.secure_uploads?
  strip_classes_and_ids
  replace_relative_urls

  @fragment.to_html
end

#to_sObject



377
378
379
# File 'lib/email/styles.rb', line 377

def to_s
  @fragment.to_s
end