# app/services/notificacao/renderizador.rb # # Blocos + dados => mensagem pronta. Duas saídas do MESMO template: # # #texto → WhatsApp (texto puro, com o *negrito* do WhatsApp) # #html → e-mail (tabelas com estilo inline, que é o que cliente de e-mail # renderiza de forma previsível) # # ⚠️ TODO texto vindo do editor e TODO valor de variável passa por escape no # caminho HTML. O corpo é digitado numa tela e viraria injeção de HTML no # e-mail — e o preview usa este mesmo renderizador, então um XSS aqui # atingiria primeiro o próprio admin. # # O preview da tela chama exatamente estes métodos: uma segunda implementação em # JavaScript inevitavelmente divergiria do que é enviado de verdade. module Notificacao class Renderizador # Cores fixas: cliente de e-mail não lê CSS externo nem variável de tema. LARANJA = '#f97316'.freeze ESCURO = '#111111'.freeze CINZA = '#666666'.freeze def initialize(blocos, dados = {}) @blocos = Array(blocos).select { |b| b.is_a?(Hash) && Blocos.valido?(b['tipo']) } @dados = (dados || {}).transform_keys(&:to_s) end # ── WhatsApp ──────────────────────────────────────────────── def texto @blocos.filter_map { |bloco| texto_do(bloco) }.join("\n\n").strip end # ── E-mail ────────────────────────────────────────────────── def html corpo = @blocos.filter_map { |bloco| html_do(bloco) }.join("\n") <<~HTML
#{corpo}
HTML end # Substitui {{variavel}} pelos dados. Variável sem valor vira string vazia — # deixar "{{valor}}" cru numa mensagem enviada é pior do que deixar o espaço. def interpolar(texto) texto.to_s.gsub(/\{\{\s*(\w+)\s*\}\}/) { @dados[Regexp.last_match(1)].to_s } end private def escapar(valor) = ERB::Util.html_escape(interpolar(valor)) def texto_do(bloco) case bloco['tipo'] when 'cabecalho' [negrito(bloco['titulo']), interpolar(bloco['subtitulo'])].reject(&:blank?).join("\n").presence when 'texto', 'rodape' interpolar(bloco['texto']).presence when 'aviso' conteudo = interpolar(bloco['texto']) conteudo.present? ? "⚠️ #{conteudo}" : nil when 'tabela' linhas = Array(bloco['linhas']).filter_map do |linha| next unless linha.is_a?(Hash) rotulo = interpolar(linha['rotulo']) valor = interpolar(linha['valor']) next if rotulo.blank? && valor.blank? "• #{rotulo}: #{valor}".strip end linhas.any? ? linhas.join("\n") : nil when 'botao' url = interpolar(bloco['url']) return nil if url.blank? [interpolar(bloco['rotulo']).presence, url].compact.join(': ') when 'divisor' '——————————' end end def negrito(valor) conteudo = interpolar(valor) conteudo.present? ? "*#{conteudo}*" : '' end def html_do(bloco) case bloco['tipo'] when 'cabecalho' then html_cabecalho(bloco) when 'texto' then html_paragrafo(bloco['texto']) when 'aviso' then html_aviso(bloco) when 'tabela' then html_tabela(bloco) when 'botao' then html_botao(bloco) when 'divisor' then %(
) when 'rodape' then html_rodape(bloco) end end def html_cabecalho(bloco) titulo = escapar(bloco['titulo']) sub = escapar(bloco['subtitulo']) return nil if titulo.blank? && sub.blank? partes = [] partes << %(

#{titulo}

) if titulo.present? partes << %(

#{sub}

) if sub.present? partes.join("\n") end # `simple_format` não serve aqui: ele produz HTML sem os estilos inline que o # cliente de e-mail precisa. As quebras de linha viram
, já escapadas. def html_paragrafo(valor) conteudo = escapar(valor) return nil if conteudo.blank? %(

#{conteudo.gsub("\n", '
')}

) end def html_aviso(bloco) conteudo = escapar(bloco['texto']) return nil if conteudo.blank? %(
#{conteudo.gsub("\n", '
')}
) end def html_tabela(bloco) linhas = Array(bloco['linhas']).filter_map do |linha| next unless linha.is_a?(Hash) rotulo = escapar(linha['rotulo']) valor = escapar(linha['valor']) next if rotulo.blank? && valor.blank? %(#{rotulo}) + %(#{valor}) end return nil if linhas.empty? %() + linhas.join + '
' end def html_botao(bloco) url = interpolar(bloco['url']).to_s.strip # Só http(s): `javascript:` num href montado na tela seria clique armado. return nil unless url.match?(%r{\Ahttps?://}i) rotulo = escapar(bloco['rotulo']).presence || 'Abrir' %(

#{rotulo}

) end def html_rodape(bloco) conteudo = escapar(bloco['texto']) return nil if conteudo.blank? %(

#{conteudo.gsub("\n", '
')}

) end end end