In Part 3, we conquered dynamic audience management using Hotwire. Our app, Mailstify, can securely manage subscribers in real-time.
Now, we shift focus to the content engine: Campaigns. A Mailchimp clone is useless if it can't create rich, beautiful emails. We will leverage Action Text, Rails' native solution for rich text editing, which is built on the Trix editor.
This installment will cover integrating Action Text, finalizing the Campaign CRUD interface, and preparing the structure for email delivery logic in Part 5.
Mailstify Table of Contents
- Mailstify #1 - The Audience Blueprint: Structuring Models for Integrity
- Mailstify #2 - User Identity and Multi-Tenancy (Built-in Auth)
- Mailstify #3 - Go Dynamic! Building Real-Time Audience Lists (The Hotwire Way)
- Mailstify #4 - Design the Campaign: Rich Content with Action Text 👈 (You are here)
1. Setup Verification: Solid Queue and Action Text
In Part 1, we installed the dependencies, but it's common for migrations to be missed. When Action Text processes images, it automatically attempts to enqueue a background job using our configured processor: Solid Queue. If the Solid Queue tables are missing, saving rich content will fail.
Let’s verify and execute any pending migrations now to prevent a 500 Internal Server Error.
A. Solid Queue
First, make sure that Solid Queue is already enabled in our project:
#config/environments/development.rb
Rails.application.configure do
# ...
config.active_job.queue_adapter = :solid_queue
# add this line
config.solid_queue.connects_to = { database: { writing: :queue } }
end
Then, update our development database config:
# config/database.yml
development:
primary:
<<: *default
database: storage/development.sqlite3
cache:
<<: *default
database: storage/development_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/development_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/development_cable.sqlite3
migrations_paths: db/cable_migrate
Finally, run these commands to ensure the solid_queue_jobs table and all other required tables are created:
# 1. Ensure the migration file is generated (safe to run again)
bin/rails solid_queue:install
# 2. Execute any migration files that are currently 'down'
bin/rails db:migrate
B. Action Text Integration
We must ensure the Campaign model is correctly linked to Action Text and that its content is permitted in the controller.
For that, confirm the following line is present in app/models/campaign.rb :
class Campaign < ApplicationRecord
# Multi-Tenancy
belongs_to :user
# Audience
belongs_to :list
# Enables the Action Text editor for the email body
has_rich_text :body # <--- This line is key!
validates :user, presence: true
validates :name, :subject, presence: true
end
C. Whitelist Action Text in Controller
The CampaignsController must be updated to permit the rich text content field, named :body.
Modify the campaign_params private method in app/controllers/campaigns_controller.rb :
# ...
private
# Use callbacks to share common setup or constraints between actions.
def set_campaign
@campaign = current_user.campaigns.find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to campaigns_url, alert: "Campaign not found or unauthorized access."
end
# Only allow a list of trusted parameters through.
def campaign_params
# Permit the Action Text field (:body)
params.require(:campaign).permit(:name, :subject, :list_id, :body)
end
end
2. Implementing the Campaign CRUD Interface
We will use a minimal set of scaffolded views (new, edit, show) to manage the campaigns, with an emphasis on the rich text editor.
A. Campaign Form (new and edit)
We need a dedicated form partial. The action_text_area helper is what renders the Trix editor interface.
Create the file app/views/campaigns/_form.html.erb :
<%= form_with(model: campaign, class: "space-y-6 bg-white p-6 shadow-md rounded-lg") do |form| %>
<% if campaign.errors.any? %>
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<p class="font-bold">Errors preventing this campaign from being saved:</p>
<ul class="list-disc list-inside mt-1">
<% campaign.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= form.hidden_field :list_id, value: @list.id %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= form.label :name, class: "block text-sm font-semibold text-gray-700 mb-1" %>
<%= form.text_field :name, class: "w-full border-gray-300 rounded-md shadow-sm p-2 focus:ring-blue-500 focus:border-blue-500" %>
</div>
<div>
<%= form.label :subject, "Email Subject Line", class: "block text-sm font-semibold text-gray-700 mb-1" %>
<%= form.text_field :subject, class: "w-full border-gray-300 rounded-md shadow-sm p-2 focus:ring-blue-500 focus:border-blue-500" %>
</div>
</div>
<div>
<%= form.label :body, "Email Content", class: "block text-sm font-semibold text-gray-700 mb-1" %>
<%= form.rich_text_area :body, class: "block w-full" %>
</div>
<div class="flex justify-end space-x-3">
<%= link_to "Cancel", lists_path, class: "py-2 px-4 text-gray-600 hover:text-gray-800" %>
<%= form.submit "Save Campaign Draft",
class: "py-2 px-4 rounded-lg shadow-md font-bold text-white bg-green-600 hover:bg-green-700 cursor-pointer transition duration-150" %>
</div>
<% end %>
B. New Campaign View
This view simply renders the form partial.
Replace the file content for app/views/campaigns/new.html.erb with:
<h2 class="text-3xl font-bold text-gray-800 mb-6">Create New Campaign for: <%= @list.name %></h2>
<%= render "form", campaign: @campaign %>
And create the edit view app/views/campaigns/edit.html.erb :
<h2 class="text-3xl font-bold text-gray-800 mb-6">Edit Campaign for: <%= @list.name %></h2>
<%= render "form", campaign: @campaign %>
C. Campaign Show View (Preview)
The show action serves as the Campaign Preview. We use a helper to correctly and safely render the rich content HTML.
Create the file app/views/campaigns/show.html.erb :
<div class="flex justify-between items-start mb-6 border-b pb-4">
<div>
<h2 class="text-3xl font-extrabold text-gray-800"><%= @campaign.name %></h2>
<p class="text-lg text-gray-600">Subject: **<%= @campaign.subject %>**</p>
<p class="text-sm text-gray-500">Target List: <%= @campaign.list.name %></p>
</div>
<div class="flex space-x-3">
<button class="bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg shadow-md cursor-pointer opacity-50" disabled>
Send Campaign (Draft)
</button>
<%= link_to "Edit", edit_campaign_path(@campaign), class: "py-2 px-4 border border-gray-300 rounded-lg shadow-sm text-gray-700 hover:bg-gray-100" %>
<%= link_to "Back to Lists", lists_path, class: "py-2 px-4 border border-gray-300 rounded-lg shadow-sm text-gray-700 hover:bg-gray-100" %>
</div>
</div>
<div class="bg-white p-8 shadow-xl rounded-lg border-2 border-gray-100">
<h3 class="text-xl font-bold mb-4 border-b pb-2">Email Preview</h3>
<div class="prose max-w-none">
<%= @campaign.body %>
</div>
</div>
3. Finalizing CRUD Actions
We must ensure the scaffolded actions in the controller correctly handle multi-tenancy (current_user) and set the required @list for the form.
A. Update Campaigns Controller
Replace the entire contents of app/controllers/campaigns_controller.rb to implement the full CRUD flow, ensuring all data is scoped and that the correct list is found for context:
class CampaignsController < ApplicationController
# Runs before show, edit, update, destroy to scope the campaign to the user
before_action :set_campaign, only: %i[ show edit update destroy ]
# Runs before new and create to set the required @list context
before_action :set_list_for_context, only: %i[ new create ]
# GET /campaigns
def index
@campaigns = Current.user.campaigns.order(created_at: :desc)
end
# GET /campaigns/new
def new
# Build campaign through user and list to set foreign keys
@campaign = Current.user.campaigns.new(list: @list)
end
# GET /campaigns/1/edit
def edit
# @campaign is set by set_campaign, @list is set from campaign's list
@list = @campaign.list
end
# GET /campaigns/1
def show
# @campaign is set by set_campaign
end
# POST /campaigns
def create
# Build through the user association to enforce multi-tenancy
@campaign = Current.user.campaigns.new(campaign_params)
respond_to do |format|
if @campaign.save
format.html { redirect_to campaign_url(@campaign), notice: "Campaign draft saved successfully." }
format.json { render :show, status: :created, location: @campaign }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @campaign.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /campaigns/1
def update
respond_to do |format|
if @campaign.update(campaign_params)
format.html { redirect_to campaign_url(@campaign), notice: "Campaign updated successfully." }
format.json { render :show, status: :ok, location: @campaign }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @campaign.errors, status: :unprocessable_entity }
end
end
end
# DELETE /campaigns/1
def destroy
@campaign.destroy!
respond_to do |format|
format.html { redirect_to campaigns_url, notice: "Campaign deleted." }
format.json { head :no_content }
end
end
private
# Scopes the Campaign lookup to the current user
def set_campaign
@campaign = Current.user.campaigns.find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to lists_url, alert: "Campaign not found or unauthorized access."
end
# Ensures the target list exists and belongs to the current user before creating a campaign
def set_list_for_context
# Requires list_id parameter from the URL (e.g., from the link in lists#show)
@list = Current.user.lists.find_by(id: params[:list_id] || campaign_params[:list_id])
redirect_to lists_url, alert: "Target list not found or unauthorized." unless @list
end
# Only allow a list of trusted parameters through.
def campaign_params
params.require(:campaign).permit(:name, :subject, :list_id, :body)
end
end
Note: If you can't render a picture, make sure you have vips (
brew install vips) installed on your machine.
Conclusion of Part 4
We have successfully integrated Action Text, giving Mailstify a powerful, native rich content editor for campaign creation. All Campaign creation and preview actions are secure and scoped to the user.
In Part 5, we will connect this rich content to Action Mailer, creating the actual email templates and setting up the preview environment for testing deliverability.

