Back to Tutorials
Tutorial11/12/2025

Mailstify #4 - Campaign Design and Rich Content (Action Text)

Mailstify #4 - Campaign Design and Rich Content (Action Text)

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


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 (neweditshow) 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 %>

Pasted image 20251110162713.png
Pasted image 20251110162713.png

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

Pasted image 20251110163010.png
Pasted image 20251110163010.png

Pasted image 20251110163048.png
Pasted image 20251110163048.png

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.

Comments

Loading comments...

Level Up Your Dev Skills & Income 💰💻

Learn how to sharpen your programming skills, monetize your expertise, and build a future-proof career — through freelancing, SaaS, digital products, or high-paying jobs.

Join 3,000+ developers learning how to earn more, improve their skills, and future-proof their careers.