Back to Tutorials
Tutorial11/26/2025

Mailstify #6: Asynchronous Bulk Dispatch (Solid Queue)

Mailstify #6: Asynchronous Bulk Dispatch (Solid Queue)

In Part 5, we successfully connected our rich-text Campaign content to Rails' email system using Action Mailer. We designed beautiful, personalized emails and verified their look in the Mailer Previews.

Now, we hit the big, scary button: Send.

Slamming that button on a list of hundreds or thousands of subscribers cannot run in the main web process. It would freeze the user's browser, cause a timeout, and make the whole app feel sluggish. We need to go asynchronous and handle the heavy lifting outside the user's request cycle.

This is where Solid Queue, Rails’ simple, reliable, and native job backend, shines. We’re building the engine that takes our single campaign and fires off thousands of personalized emails in the background.

This reliance on native Rails tools extends to the database. Since Solid Queue is a database-backed job runner, all job data (job class, arguments, queue, execution status) is persistently stored in dedicated tables within your existing database. To see the structure, check out the generated queue tables in db/queue_schema.rb

Mailstify Table of Contents


1. Generating the Active Job Class

Active Job is Rails' standard API for abstracting background work. We need a dedicated job to handle the bulk processing: iterating over the list and enqueuing the individual emails.

Run the generator:

bin/rails generate job campaign_dispatch

This creates the file we need: app/jobs/campaign_dispatch_job.rb.


2. Writing the Bulk Dispatch Logic

Open app/jobs/campaign_dispatch_job.rb. This job will be passed the Campaign object. Its mission is to be fast by delegating the actual network I/O (sending the email) to smaller, individual jobs.

The Strategy: The CampaignDispatchJob does not send emails. It simply iterates over the list's subscribers and tells Active Job to enqueue a CampaignMailer delivery for each one. This is key for personalization and stability.

# app/jobs/campaign_dispatch_job.rb

class CampaignDispatchJob < ApplicationJob
  # Use the standard queue defined in config/application.rb
  queue_as :default

  def perform(campaign.id)
    # Reload the campaign to ensure we have the latest version (e.g., status is up-to-date)
    @campaign = Campaign.find(campaign_id)campaign.reload
    @list = @campaign.list

    # --- STATUS UPDATE (Will be finalized in Part 7) ---
    # @campaign.update!(status: :sending)
    # ----------------------------------------------------

    # Find subscribers belonging to the campaign's list.
    # We use .find_each to handle huge lists efficiently, batching the database queries.
    @list.subscribers.find_each do |subscriber|

      # IMPORTANT: Use #deliver_later to enqueue the *mailer* job.
      # This delegates the actual email network call to a new, small Active Job.
      CampaignMailer.campaign_email(@campaign, subscriber).deliver_later

    end

    # --- STATUS UPDATE (Will be finalized in Part 7) ---
    # @campaign.update!(status: :sent)
    # ----------------------------------------------------
  end
end

Boom. This is the core engine. It's clean, efficient, and built entirely using native Rails tools (ApplicationJobfind_eachdeliver_later).


3. Implementing the "Send Campaign" Action

We need to create the endpoint that the user's "Send" button will hit. This action's sole responsibility is to enqueue the bulk job and return a response immediately.

A. Define the Route

Add a custom member route to config/routes.rb inside your resources :campaigns block. This creates the route POST /campaigns/:id/send_campaign.

# app/controllers/campaigns_controller.rb
class CampaignsController < ApplicationController
  # ... existing code ...

  def send_campaign
    @campaign = Current.user.campaigns.find(params[:id])

    # Basic check: Ensure we have content before enqueuing
    unless @campaign.body.present?
      redirect_to @campaign, alert: "Can't dispatch an empty campaign!" and return
    end

    # The zero-dependency magic line: Enqueue the job!
    CampaignDispatchJob.perform_later(@campaign.id)

    redirect_to @campaign, notice: "Campaign dispatch job enqueued! Emails will be sent shortly."
  rescue ActiveRecord::RecordNotFound
    redirect_to lists_url, alert: "Campaign not found or unauthorized access."
  end
  # ... private methods below ...
end

C. Add a "Dispatch" Button to the Preview

We need to update the campaigns/show.html.erb preview page to display the button. Since we're not using the status yet (that's Part 7), let's enable the button.

Modify the campaign show view's header to enable the "Send Campaign Now" button

<%# app/views/campaigns/show.html.erb (The buttons section) %>

<div class="flex justify-between items-start mb-6 border-b pb-4">
  <div>
    <%# ... campaign name/subject ... %>
  </div>

  <div class="flex space-x-3">
    <%# Replace the disabled button with a functional one %>
    <%= button_to "Send Campaign Now",
                  send_campaign_campaign_path(@campaign),
                  method: :post,
                  data: { turbo_confirm: "Are you sure you want to send this to #{@campaign.list.subscribers.count} subscribers?" },
                  class: "bg-green-600 text-white font-semibold py-2 px-4 rounded-lg shadow-md cursor-pointer hover:bg-green-700 transition duration-150" %>

    <%= 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>

<%# ... rest of the campaign show view ... %>

D. Displaying the Flash Message

To ensure the success notification is visible after the redirect, you must add the flash rendering logic to the application layout.

Insert the following code block inside the <div class="container mx-auto p-4 md:p-8"> tag, and before the <header> tag in app/views/layouts/application.html.erb.

<!DOCTYPE html>
<html>
  # ....
  <body class="bg-gray-50 min-h-screen">
    <div class="container mx-auto p-4 md:p-8">

      <%# --- START: Flash Message Display --- %>
      <% flash.each do |type, msg| %>
        <%# Map Rails flash types (notice, alert) to Tailwind classes %>
        <% css_class = type.to_s == 'notice' ? 'bg-green-100 border-green-400 text-green-700' : 'bg-red-100 border-red-400 text-red-700' %>
        <div class="<%= css_class %> px-4 py-3 rounded relative mb-4 border" role="alert">
          <strong class="font-bold"><%= type.capitalize %>:</strong>
          <span class="block sm:inline"><%= msg %></span>
        </div>
      <% end %>
      <%# --- END: Flash Message Display --- %>

      <header class="mb-8 border-b pb-4 flex items-center justify-between">
	    </header>

      # ....
    </div>
  </body>
</html>

4. Setting Up and Monitoring the Solid Queue Worker

Remember, running the app with rails s handles the web server, but not the background jobs. We need to start the dedicated worker process that will be listening for jobs in the Solid Queue tables.

A. Start the Worker Process

Open a separate terminal window from the one running your main rails s server. This new terminal is the lifeblood of our asynchronous system.

bin/rails solid_queue:start

The worker will boot up, showing that it's monitoring the queues defined in your setup (like :default).

B. Ensuring Worker Visibility (Logging Fix)

To ensure the worker prints its execution logs to the terminal, we need to explicitly set the logger to output to $stdout in development.

Add the following configuration to config/environments/development.rb:

Rails.application.configure do
  # ...
  config.logger = ActiveSupport::Logger.new($stdout)
  # ...
end

Restart your web server and worker. Now, when you click "Send":

  1. Your browser displays the flash message (e.g., "Campaign dispatch job enqueued!").
  2. Your worker terminal will immediately show the logs proving that:
    • The CampaignDispatchJob is picked up.
    • The worker picks up each ActionMailer::MailDeliveryJob and performs the actual email sending.

You have successfully built a robust, non-blocking, asynchronous bulk email dispatch system using only native Rails tools!

SCR-20251126-nkcf.png
SCR-20251126-nkcf.png


Conclusion of Part 6: Full Async Power

We've connected the final piece of our Mailstify engine. By leveraging Active Job and Solid Queue, we now have a production-ready mechanism for sending thousands of emails without blocking the user interface. This is the difference between a simple CRUD app and a true, professional-grade email service.

In Part 7, we'll focus on the final polish: adding a simple status indicator for campaigns (Draft, Sending, Sent) and implementing a basic history log/counter for sent emails, officially wrapping up our zero-dependency Mailchimp clone.

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.

Mailstify #6: Asynchronous Bulk Dispatch (Solid Queue) | Devmystify