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(/
+
+
+
+
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: "< > & \""; }
+