# frozen_string_literal: true module Tournaments class ComposeInviteEmail LINK_TOKEN = "{{link_invito}}" EXPIRY_TOKEN = "{{scadenza}}" MAX_HTML_BYTES = 100_000 ALLOWED_TAGS = %w[p br strong b em i u ul ol li a img h2 h3 h4 span div blockquote].freeze ALLOWED_ATTR = %w[href src alt width height style target rel data-invite-note].freeze ALLOWED_STYLE = %w[ width height max-width min-width margin margin-top margin-bottom margin-left margin-right padding padding-top padding-bottom padding-left padding-right float display text-align color background background-color font-weight font-size line-height text-decoration border-radius border ].freeze def self.call(html:, invite_url:, expires_on: nil) new(html: html, invite_url: invite_url, expires_on: expires_on).call end def self.sanitize_html(html) new(html: html.to_s, invite_url: "").sanitize(html.to_s) end def self.default_html(tournament:, invited_by:) intro = I18n.t( "mailers.tournament_invite.body_html", inviter: CGI.escapeHTML(invited_by.name.to_s), tournament: CGI.escapeHTML(tournament.name.to_s), club: CGI.escapeHTML(tournament.club.name.to_s), assignment: I18n.t("tournaments.hub.invite_email_scope_generic") ) cta = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta")) <<~HTML
#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.hello"))}
#{intro}
#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.link_fallback"))}
#{LINK_TOKEN}
#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.steps"))}
#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.expiry", date: EXPIRY_TOKEN))}
#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.ignore"))}
HTML end def initialize(html:, invite_url:, expires_on: nil) @html = html.to_s @invite_url = invite_url.to_s @expires_on = expires_on.to_s end def call html = @html.dup html.gsub!(LINK_TOKEN, @invite_url) html.gsub!(EXPIRY_TOKEN, @expires_on) if @expires_on.present? sanitized = sanitize(html) sanitized += fallback_cta if @invite_url.present? && !sanitized.include?(@invite_url) sanitized end def sanitize(html) html = html.bytesize > MAX_HTML_BYTES ? html.byteslice(0, MAX_HTML_BYTES) : html fragment = Loofah.fragment(html.to_s) fragment.css("[data-invite-note]").each do |el| el.remove if el.text.to_s.strip.blank? end scrubber = Rails::HTML::PermitScrubber.new scrubber.tags = ALLOWED_TAGS scrubber.attributes = ALLOWED_ATTR fragment.scrub!(scrubber) fragment.css("a").each { |node| scrub_url!(node, "href") } fragment.css("img").each do |node| scrub_url!(node, "src") end fragment.css("[style]").each { |node| scrub_style!(node) } fragment.css("a[target='_blank']").each do |node| node["rel"] = "noopener noreferrer" end fragment.to_s end private def scrub_url!(node, attr) url = node[attr].to_s.strip return if url == LINK_TOKEN || url.include?(LINK_TOKEN) return if url.match?(/\Ahttps?:\/\//i) || url.start_with?("/rails/active_storage") if attr == "src" node.remove else node.remove_attribute(attr) end end def scrub_style!(node) decls = node["style"].to_s.split(";").map(&:strip).reject(&:blank?) kept = decls.select do |decl| prop, value = decl.split(":", 2).map { |part| part.to_s.strip } next false if prop.blank? || value.blank? next false unless ALLOWED_STYLE.include?(prop.downcase) next false if value.match?(/expression|javascript|url\s*\(/i) true end if kept.any? node["style"] = kept.join("; ") else node.remove_attribute("style") end end def fallback_cta label = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta")) url = CGI.escapeHTML(@invite_url) %(#{url}
) end end end