diff --git a/app/controllers/mailbox/email_templates_controller.rb b/app/controllers/mailbox/email_templates_controller.rb index 90606f0fc..29a710481 100644 --- a/app/controllers/mailbox/email_templates_controller.rb +++ b/app/controllers/mailbox/email_templates_controller.rb @@ -1,7 +1,7 @@ class Mailbox::EmailTemplatesController < Mailbox::BaseController def index - + @email_templates = EmailTemplate.all end def new @@ -13,17 +13,55 @@ def show def create html_doc = params[:htmldoc] re_doc = params[:redoc] + name = params[:name] + + email_template = EmailTemplate.create!(html: html_doc, template: re_doc, name: name) - - render json: { id: 1 } + render json: { id: email_template.id } end def edit + @email_template = EmailTemplate.find(params[:id]) + end + + def update + email_template = EmailTemplate.find(params[:id]) html_doc = params[:htmldoc] re_doc = params[:redoc] + name = params[:name] + email_template.update!(html: html_doc, template: re_doc, name: name) + + render json: { id: email_template.id } end - def destroy + def test_send + email_template = EmailTemplate.find(params[:id]) + dynamic_segments = email_template.dynamic_segments + symbol_mapping = ActiveSupport::HashWithIndifferentAccess.new + dynamic_segments.each do |segment| + symbol_mapping[segment] = params[segment] + end + + email_body = email_template.inject_dynamic_segments(symbol_mapping) + from_address = "#{Apartment::Tenant.current}@#{ENV['APP_HOST']}" + email_subject = "#{email_template.name} test email" + email_thread = MessageThread.create!(recipients: [current_user.email], subject: email_subject) + email_body.encode!('UTF-16', 'UTF-8', :invalid => :replace, :replace => '') + email_body.encode!('UTF-8', 'UTF-16') + email_message = email_thread.messages.create!( + content: email_body, + from: from_address + ) + EMailer.with(message: email_message, message_thread: email_thread).ship.deliver_later + flash.notice = "Template test email sent to #{current_user.email}" + redirect_to edit_mailbox_email_template_path(email_template.id) + end + + def destroy + email_template = EmailTemplate.find(params[:id]) + flash.notice = "#{email_template.name} destroyed!" + email_template.destroy! + redirect_to mailbox_email_templates_path end end \ No newline at end of file diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index f1bca9f6c..32e1be397 100755 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -1,2 +1,38 @@ module MessagesHelper + # Extract the visible email content from HTML, removing meta tags, style tags, etc. + # This is used to display email content in the message thread view and email sending + def render_email_content(message) + return '' if message.content.blank? + + # Use to_s to preserve ActionText attachments + html_content = message.content.to_s + + # Parse the HTML + doc = Nokogiri::HTML.fragment(html_content) + + # Remove the trix-content wrapper div if present (do this first) + trix_div = doc.at_css('div.trix-content') + if trix_div + # Replace the trix-content div with its children + trix_div.replace(trix_div.children.to_html) + # Re-parse after removing trix wrapper + doc = Nokogiri::HTML.fragment(doc.to_html) + end + + # Remove meta tags, style tags, title, and link tags + doc.css('meta').remove + doc.css('style').remove + doc.css('title').remove + doc.css('link').remove + + # Get the HTML string + result = doc.to_html + + # Remove leading text nodes that appear before the first HTML tag + # This handles orphaned text like "My Email" from the title tag + # Match any text at the beginning that comes before the first < character + result = result.sub(/\A([^<]*?)(<)/, '\2') + + result.html_safe + end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 7f643573a..c8f67146f 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,6 @@ class ApplicationMailer < ActionMailer::Base + helper MessagesHelper + default from: "#{Apartment::Tenant.current}@#{ENV['APP_HOST']}" default "Message-ID" => lambda {"#{Digest::SHA2.hexdigest(Time.now.to_i.to_s)}@#{ENV['APP_HOST']}"} end diff --git a/app/models/email_template.rb b/app/models/email_template.rb new file mode 100644 index 000000000..6fb275d34 --- /dev/null +++ b/app/models/email_template.rb @@ -0,0 +1,45 @@ +class EmailTemplate < ApplicationRecord + extend FriendlyId + friendly_id :name, use: :slugged + validates :name, presence: true + + SYMBOL_CAPTURE_PATTERN = /(?<=\{\{)[a-z]+(?:\.[a-z]+)*(?=\}\})/ + + def dynamic_segments + html_doc = Base64.decode64(self.html) + html_doc.scan(SYMBOL_CAPTURE_PATTERN) + end + + def inject_dynamic_segments(properties_obj = {}) + # pass a hash of properties or a API Resource instance and get dynamic HTML + # usage option 1: EmailTemplate.last.inject_dynamic_segments({name: 'hideo kojima', link: "https://mgs.snake"}) + # usage option 2: EmailTemplate.last.inject_dynamic_segments(ApiResource.last) + # works by extracting any variable defined between: {{}} + # example of including a variable called 'name': {{name}} + + html_doc = Base64.decode64(self.html) + symbols = self.dynamic_segments + symbol_mapping = ActiveSupport::HashWithIndifferentAccess.new + output = html_doc + + if properties_obj.class == ApiResource + api_resource = properties_obj + symbols.each do |symbol| + symbol_mapping[symbol] = api_resource.properties[symbol] + end + elsif properties_obj.class == Hash || properties_obj.class == ActiveSupport::HashWithIndifferentAccess + symbols.each do |symbol| + symbol_mapping[symbol] = properties_obj.with_indifferent_access[symbol] + end + else + raise "dynamic segment mapping for Email Template is unrecognized. Please pass a hash of properties or API Resource instance" + end + + symbols.each do |symbol| + mapped_value = symbol_mapping[symbol] + raise "dynamic segment token has no value, please ensure a value is provided" if mapped_value.nil? || mapped_value.empty? + output = output.gsub("{{#{symbol}}}", mapped_value) + end + return output + end +end diff --git a/app/views/e_mailer/ship.html.erb b/app/views/e_mailer/ship.html.erb index e4f0c01ed..ca2ab8338 100644 --- a/app/views/e_mailer/ship.html.erb +++ b/app/views/e_mailer/ship.html.erb @@ -7,7 +7,7 @@ <% tracking_link = "#{root_url(subdomain: @subdomain.subdomain_name)}email_tracking/open/?emails=#{@recipients.join(',')}&message_id=#{@message.id}&email_uuid=#{@message.email_message_id}" %> <% end %> - <%= @message.content %> + <%= render_email_content(@message).html_safe %> <% if @subdomain.email_signature.present? %>
<%= @subdomain.email_signature %> diff --git a/app/views/mailbox/email_templates/edit.html.haml b/app/views/mailbox/email_templates/edit.html.haml new file mode 100644 index 000000000..985567606 --- /dev/null +++ b/app/views/mailbox/email_templates/edit.html.haml @@ -0,0 +1,78 @@ +.page-header + .h2 + = link_to 'Email Templates', mailbox_email_templates_path +.container.my-2 + = label_tag "name" + = text_field_tag "name", @email_template.name, id: 'email_template_name' +.container.my-2 + = label_tag "Slug: " + = @email_template.slug +.container.my-2 + = label_tag "Dynamic segments ( defined as {{}} ): " + = @email_template.dynamic_segments.join(', ') +.container.my-2 + = label_tag "Test send: " + = form_tag(test_send_mailbox_email_template_path(@email_template.id), method: "POST", class: 'form-group') do + - @email_template.dynamic_segments.each do |segment| + = label_tag(segment.to_sym, "#{segment}:") + = text_field_tag(segment.to_sym, nil, class: 'form-control', required: true) + = submit_tag("Email me this template!", class: 'btn btn-secondary my-2') +.container.my-2 + = label_tag "Copy HTML: " + = button_tag "Copy to clipboard", id: 'copyRevolvHtml' + +.container + #VioletEmailEditor + + %button#VioletEmailEditorSubmit.btn.btn-primary.my-2 + = "Save" + = link_to "Delete", mailbox_email_template_path(@email_template.id), method: :delete, class: 'btn btn-danger', data: { confirm: 'Are you sure?' } + +:javascript + let template = "#{escape_javascript(@email_template.template)}" + let id = "#{@email_template.id}" + const app = Revolvapp('#VioletEmailEditor', { + editor: { + path: '/revolvapp-2-3-10/', + }, + content: template + }); + + $('#copyRevolvHtml').click(function() { + let htmldoc = app.editor.getHtml() + + navigator.clipboard.writeText(htmldoc); + + // Alert the copied text + alert("Copied html"); + }); + + $('#VioletEmailEditorSubmit').click(function() { + $("#VioletEmailEditorSubmit").prop("disabled", true) + let authenticityToken = $('meta[name="csrf-token"]').attr('content'); + + let htmldoc = app.editor.getHtml() + let redoc = app.editor.getTemplate(true) + let name = $('#email_template_name').val() + const endpoint = "#{mailbox_email_template_path}" + + if (name && htmldoc) { + $.ajax({ + url: endpoint, + type: 'PATCH', + headers: { + 'X-CSRF-Token': authenticityToken + }, + data: JSON.stringify({ htmldoc: btoa(htmldoc), redoc: redoc, name: name, id: id }), + contentType: "application/json; charset=utf-8", + success: function(response) { + window.location.href = `${endpoint}/${response.id}/edit` + window.location.reload() + } + }); + } else { + window.alert('Please give your template a name and set its content') + } + + $("#VioletEmailEditorSubmit").prop("disabled", false) + }); \ No newline at end of file diff --git a/app/views/mailbox/email_templates/index.html.haml b/app/views/mailbox/email_templates/index.html.haml new file mode 100644 index 000000000..f3ceb7b18 --- /dev/null +++ b/app/views/mailbox/email_templates/index.html.haml @@ -0,0 +1,9 @@ +.page-header + = link_to 'New Email Template', new_mailbox_email_template_path, class: 'btn btn-secondary float-right mx-1' + %h2 + = link_to "Email", mailbox_path + Templates +%ol + - @email_templates.each do |email_template| + %li + = link_to email_template.name, edit_mailbox_email_template_path(email_template.id) \ No newline at end of file diff --git a/app/views/mailbox/email_templates/new.html.haml b/app/views/mailbox/email_templates/new.html.haml index 6d86982f0..6f0d7e6b5 100644 --- a/app/views/mailbox/email_templates/new.html.haml +++ b/app/views/mailbox/email_templates/new.html.haml @@ -1,6 +1,11 @@ .page-header .h2 = 'Email Templates' + .container.m-2 + .h3 + = "Docs:" + %a{href: "https://imperavi.com/legacy/revolvapp/docs/syntax/tags/", target: '_blank'} + Imperavi Revolvapp .container.m-2 .h3 = "AI prompt:" @@ -10,10 +15,14 @@ = "... copy pasted editor code" .p = "using the above markup language create me a..." +.container.my-2 + = label_tag "name" + = text_field_tag "name", nil, id: 'email_template_name' + .container #VioletEmailEditor -%button#VioletEmailEditorSubmit +%button#VioletEmailEditorSubmit.btn.btn-primary.my-2 = "Save" :javascript @@ -23,7 +32,6 @@ template: '/revolvapp-2-3-10/templates/index.html', } }); - app.start() $('#VioletEmailEditorSubmit').click(function() { $("#VioletEmailEditorSubmit").prop("disabled", true) @@ -31,18 +39,24 @@ let htmldoc = app.editor.getHtml() let redoc = app.editor.getTemplate(true) + let name = $('#email_template_name').val() const endpoint = "#{mailbox_email_templates_path}" + if (name && htmldoc) { + $.ajax({ + url: endpoint, + type: 'POST', + headers: { + 'X-CSRF-Token': authenticityToken + }, + data: JSON.stringify({ htmldoc: btoa(htmldoc), redoc: redoc, name: name }), + contentType: "application/json; charset=utf-8", + success: function(response) { + window.location.href = `${endpoint}/${response.id}/edit` + } + }); + } else { + window.alert('Please give your template a name and set its content') + } - $.ajax({ - url: endpoint, - type: 'POST', - headers: { - 'X-CSRF-Token': authenticityToken - }, - data: JSON.stringify({ htmldoc: htmldoc, redoc: redoc }), - contentType: "application/json; charset=utf-8", - success: function(response) { - window.location.href = `${endpoint}/${response.id}` - } - }); + $("#VioletEmailEditorSubmit").prop("disabled", false) }); \ No newline at end of file diff --git a/app/views/mailbox/mailbox/show.html.haml b/app/views/mailbox/mailbox/show.html.haml index 054fc880e..57d470d40 100644 --- a/app/views/mailbox/mailbox/show.html.haml +++ b/app/views/mailbox/mailbox/show.html.haml @@ -3,7 +3,7 @@ .page-header = link_to I18n.t('views.mailbox.index.header.actions.new'), new_mailbox_message_thread_path, class: 'btn btn-success float-right mx-1' - = link_to 'Email Templates', new_mailbox_email_template_path, class: 'btn btn-secondary float-right mx-1' + = link_to 'Email Templates', mailbox_email_templates_path, class: 'btn btn-secondary float-right mx-1' .h2 = I18n.t('views.mailbox.index.header.title') = Subdomain.current.mailing_address diff --git a/app/views/mailbox/message_threads/show.html.haml b/app/views/mailbox/message_threads/show.html.haml index e3a174e0f..010e4084f 100644 --- a/app/views/mailbox/message_threads/show.html.haml +++ b/app/views/mailbox/message_threads/show.html.haml @@ -9,17 +9,19 @@ = render partial: 'messages/form', locals: { f: f, render_submit: true } - @message_thread.messages.each do |message| .card.my-3 - .card-body + .card-body .card-subtitle.mb-2.text-muted = message.from - if !message.from && Subdomain.current.track_email_opens .card-subtitle.mb-2.text-muted = message.opened ? 'opened' : 'not opened yet' .card-text.bg-light.px-2.py-3 - = message.content + = render_email_content(message) - if message.attachments.any? .card-text.bg-dark.px-2.py-3.text-white Attachments %ul{class: 'list-group'} - message.attachments.each do |attachment| = link_to attachment.filename, rails_blob_path(attachment, disposition: 'attachment') + + diff --git a/config/initializers/action_text.rb b/config/initializers/action_text.rb new file mode 100644 index 000000000..be775b52e --- /dev/null +++ b/config/initializers/action_text.rb @@ -0,0 +1,25 @@ +# Configure ActionText to allow style tags and attributes +# This is necessary for email templates (e.g., Revolvapp) that include CSS styles and tables +Rails.application.config.to_prepare do + # Allow the style attribute on all tags + ActionText::ContentHelper.allowed_attributes.add('style') + ActionText::ContentHelper.allowed_attributes.add('cellpadding') + ActionText::ContentHelper.allowed_attributes.add('cellspacing') + ActionText::ContentHelper.allowed_attributes.add('border') + ActionText::ContentHelper.allowed_attributes.add('width') + ActionText::ContentHelper.allowed_attributes.add('align') + ActionText::ContentHelper.allowed_attributes.add('valign') + ActionText::ContentHelper.allowed_attributes.add('bgcolor') + + # Allow the style tag itself + ActionText::ContentHelper.allowed_tags.add('style') + + # Allow table-related tags for email templates + ActionText::ContentHelper.allowed_tags.add('table') + ActionText::ContentHelper.allowed_tags.add('tbody') + ActionText::ContentHelper.allowed_tags.add('thead') + ActionText::ContentHelper.allowed_tags.add('tfoot') + ActionText::ContentHelper.allowed_tags.add('tr') + ActionText::ContentHelper.allowed_tags.add('td') + ActionText::ContentHelper.allowed_tags.add('th') +end diff --git a/config/routes.rb b/config/routes.rb index 6ea78bee7..359404c82 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,7 +57,11 @@ def self.matches?(request) patch 'add_categories' end end - resources :email_templates, controller: 'mailbox/email_templates' + resources :email_templates, controller: 'mailbox/email_templates' do + member do + post 'test_send' + end + end end # email tracking diff --git a/db/migrate/20251122174516_create_email_templates.rb b/db/migrate/20251122174516_create_email_templates.rb new file mode 100644 index 000000000..44694663d --- /dev/null +++ b/db/migrate/20251122174516_create_email_templates.rb @@ -0,0 +1,12 @@ +class CreateEmailTemplates < ActiveRecord::Migration[6.1] + def change + create_table :email_templates do |t| + t.text :html + t.text :template + t.string :name + t.string :slug + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 5ab394846..52f2f7c80 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -403,6 +403,15 @@ t.index ["page_id"], name: "index_comfy_cms_translations_on_page_id" end + create_table "email_templates", force: :cascade do |t| + t.text "html" + t.text "template" + t.string "name" + t.string "slug" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + create_table "external_api_clients", force: :cascade do |t| t.bigint "api_namespace_id", null: false t.string "slug", null: false diff --git a/test/controllers/mailbox/message_threads_controller_test.rb b/test/controllers/mailbox/message_threads_controller_test.rb index cc2d007f0..18d37d952 100644 --- a/test/controllers/mailbox/message_threads_controller_test.rb +++ b/test/controllers/mailbox/message_threads_controller_test.rb @@ -225,4 +225,47 @@ class Mailbox::MessageThreadsControllerTest < ActionDispatch::IntegrationTest test 'viewing unread thread sets unread:true' do # todo test https://github.com/restarone/violet_rails/blob/9476c661537a1688a81c95802d5f49a6617f0678/app/controllers/mailbox/message_threads_controller.rb end + + test 'renders email content with style tags properly' do + sign_in(@user) + + # Create a message with HTML content including meta, style, and title tags (like Revolvapp templates) + html_content = <<~HTML + + My Email + +
Test content
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message_thread = MessageThread.create!( + unread: true, + subject: 'Test Email with Styles', + recipients: [@user.email] + ) + message = message_thread.messages.create!(content: html_content) + + get mailbox_message_thread_url(subdomain: @subdomain.name, id: message_thread.id) + assert_response :success + + # Extract the message content area from the response + # The message content is rendered in a div with class 'card-text bg-light px-2 py-3' + message_content_match = response.body.match(/
(.*?)<\/div>/m) + assert message_content_match, "Could not find message content in response" + message_content = message_content_match[1] + + # Verify that meta, style, title tags are NOT present in the message content + refute_match(/ + + My Email + + +
Test content
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify meta, style, title, and link tags are removed + refute_match(/ +

Welcome

+

This is a test email.

+
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify content is present + assert_match(/Welcome/, result) + assert_match(/This is a test email/, result) + assert_match(/
+ + + + + +
+

Hello World

+
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify meta and style tags are removed + refute_match(/body { margin: 0; } +
Content 1
+ +
Content 2
+ +
Content 3
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify all style tags are removed + refute_match(/ +

Nested content

+
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify style tag is removed + refute_match(/ + + Logo + +

Click the logo above

+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify meta and style tags are removed + refute_match(/ + + Newsletter + + + + + + +
+

Newsletter Title

+

Newsletter content goes here.

+
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify all head tags are removed + refute_match(/.special { content: "< > & \""; } +
+

Special chars: < > & "

+

Symbols: © ® ™

+
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify style tag is removed + refute_match(/ + +
+

Content with whitespace

+
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify tags are removed + refute_match(/ + + +
Content
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify self-closing tags are removed + refute_match(/ + + +
+ +

Content

+
+ HTML + + Apartment::Tenant.switch @subdomain.name do + message = @message_thread.messages.create!(content: html_content) + result = render_email_content(message) + + # Verify head tags are removed + refute_match(/