Welcome to the first installment of our tutorial series, Mailstify: Building a Simple Mailchimp Clone with Native with Ruby on Rails 8.
Mailstify Table of Contents
- Mailstify #1 - Let's Build A Zero-Dependency Mailchimp Clone in with Ruby on Rails 8 👈 (You are here)
- Mailstify #2 - User Identity and Multi-Tenancy (Built-in Auth)
Introduction
What is Mailchimp? Mailchimp is a widely used email marketing platform that allows users to manage subscriber lists (Audiences), design rich email content (Campaigns), and dispatch those campaigns in bulk. Our simple clone will focus on implementing these core features: Audience Management (Lists & Subscribers) and Asynchronous Campaign Dispatch.
The Rails 8 Native Principle: Crucially, we are adhering to a zero-external-dependency principle for core functionality. This project leverages Rails 8's powerful built-in toolset to demonstrate modern Rails development:
- Action Text: For creating rich-text email content within Campaigns.
- Action Mailer: For handling the email sending logic.
- Active Job / Solid Queue: For offloading the bulk email dispatch to the background, ensuring fast, responsive user interactions without using external services like Redis or Sidekiq.
- Hotwire (Turbo & Stimulus): For modern, dynamic front-end updates and list filtering without writing custom JavaScript.
Let's begin by setting up the project and defining the data structure.
1. Project Setup and Initialization
We start by generating a new Rails application. We will use SQLite for simplicity and development speed, and add the --skip-test flag to focus solely on the implementation.
# Generate the new Rails project
rails new mailchimp_clone --skip-test
cd mailchimp_clone
# Run bin/setup to prepare the database
bin/setup
Install Action Text & Solid Queue Dependencies
Since we are using Action Text and Solid Queue (the native job backend) which require their own tables, we need to run their setup commands now.
# 1. Install default dependencies
bundle install
# 2. Install Action Text (for rich content editor)
bin/rails action_text:install
# 3. Install Solid Queue (for asynchronous background jobs)
# This creates the necessary configuration and database schema files.
bin/rails solid_queue:install
# 4. Run migration
rails db:migrate
Confirm the server starts correctly:
rails s
# => Access <http://localhost:3000> to verify the default Rails page.
2. Core Model Design and Generation
Our system requires three interconnected models: List, Subscriber, and Campaign. We will use the built-in scaffold generator where appropriate to rapidly build CRUD interfaces.
A. Model: List (The Audience Container)
The List model organizes our subscribers.
# Use scaffold for quick CRUD interface
bin/rails generate scaffold List name:string
# Model: List
# - List has many Subscribers
# - List has many Campaigns
B. Model: Subscriber (The Contact)
The Subscriber is the individual recipient. Each subscriber belongs to a specific List.
# Use scaffold, defining the foreign key (list:references)
bin/rails generate scaffold Subscriber email:string name:string list:references
# Model: Subscriber
# - Subscriber belongs to a List
C. Model: Campaign (The Email Content)
The Campaign holds the subject and content. Since the content is rich text, we only define the name and subject fields, and the list:references foreign key.
# Use model generator, as Action Text will handle the content field (body)
bin/rails generate model Campaign name:string subject:string list:references
D. Running Migrations
Execute the migrations to create all necessary tables, including the ones for Action Text and Solid Queue:
bin/rails db:migrate
3. Defining Associations and Validations
The generated models must be updated to enforce data integrity and define the correct object relationships. This is where we ensure the required associations are in place for the console demonstration.
A. app/models/list.rb
The List model must manage its dependencies, defining the associations and ensuring that if a list is deleted, its associated subscribers and campaigns are also destroyed (dependent: :destroy).
# app/models/list.rb
class List < ApplicationRecord
has_many :subscribers, dependent: :destroy
has_many :campaigns, dependent: :destroy
end
B. app/models/subscriber.rb
We must ensure the email format is valid and enforce the critical business rule: an email can only be subscribed once to the same list.
# app/models/subscriber.rb
class Subscriber < ApplicationRecord
belongs_to :list
# Validation (must be added manually after scaffold)
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
# Critical business rule: scoped uniqueness
validates :email, uniqueness: { scope: :list_id, message: "is already subscribed to this list" }
end
C. app/models/campaign.rb
The Campaign must be linked to a target list and be equipped with the Action Text rich editor for the email body.
# app/models/campaign.rb
class Campaign < ApplicationRecord
belongs_to :list
# This line connects the Campaign to the ActionText table
has_rich_text :body
# Basic validations (must be added manually)
validates :name, :subject, presence: true
end
D. Configuring Solid Queue
To use Solid Queue, we must tell Active Job to use it as the adapter. Open config/environments/development.rb and add the following line inside the Rails.application.configure do block:
# config/environments/development.rb
config.active_job.queue_adapter = :solid_queue
4. Console Demonstration: Testing the Models
Before moving on, let's use the Rails console to immediately verify that our models, associations, and validations are working as expected.
bin/rails c
A. Creating and Associating Data
We will create one list, one subscriber, and one campaign.
# 1. Create the main List
main_list = List.create(name: 'Marketing Newsletter')
puts "List ID: #{main_list.id}"
# => List ID: 1
# 2. Create the first Subscriber (Success)
sub_alpha = main_list.subscribers.create(name: "Alex", email: "alex@example.com")
puts "Subscriber Alpha valid? #{sub_alpha.valid?}"
# => Subscriber Alpha valid? true
# 3. Create the Campaign for the List
campaign_q3 = main_list.campaigns.create(name: "Q3 Product Launch",
subject: "Check out our new features!",
body: "Welcome to our Q3 product launch!")
puts "Campaign ID: #{campaign_q3.id}"
# => Campaign ID: 1
B. Verifying Associations
Use the objects to navigate the relationships we defined:
# Verify List -> Subscribers association
puts "Subscribers in List: #{main_list.subscribers.count}"
# => Subscribers in List: 1
# Verify Subscriber -> List association
puts "Subscriber Alpha's List Name: #{sub_alpha.list.name}"
# => Subscriber Alpha's List Name: Marketing Newsletter
# Verify List -> Campaign association
puts "List Campaigns: #{main_list.campaigns.first.name}"
# => List Campaigns: Q3 Product Launch
C. Testing Uniqueness Validation
Now, try to add the exact same subscriber to the same list. This must fail due to the uniqueness: { scope: :list_id } validation.
# Attempt to create a duplicate subscriber on the same list
sub_duplicate = main_list.subscribers.create(name: "Alex 2", email: "alex@example.com")
puts "Subscriber Duplicate valid? #{sub_duplicate.valid?}"
# => Subscriber Duplicate valid? false
# Check the specific error message
puts "Validation Error: #{sub_duplicate.errors.full_messages.join(', ')}"
# => Validation Error: Email is already subscribed to this list
The console confirms that all three models, their associations, and the critical scoped uniqueness validation are implemented correctly.
5. Setting the Root Route
To finalize the foundation, we define the application's starting point.
# config/routes.rb
Rails.application.routes.draw do
resources :campaigns
resources :subscribers
resources :lists
# Sets the root path
root "lists#index"
end
Restart the server (rails s). Visiting http://localhost:3000 will now display the basic Lists management page, ready for data entry.
You can find all the source code for this tutorial here: Mailstify .
Conclusion of Part 1
We have established a solid data structure and verified its integrity using the Rails console. This foundation adheres strictly to the Rails Native principle. In Part 2, we will focus on Audience Management and Hotwire integration, enhancing the Subscribers interface to allow for dynamic addition and viewing of contacts within a specific list, mimicking the list view functionality of Mailchimp.

