Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

This recipe describes the classes, controllers, and jobs used by Standard Notes to build our own custom-fit Mailchimp replacement.

With the parts here, you should be able to put together your own email campaign system directly in your Rails app, removing the need to use an expensive online newsletter service.

namespace :deploy do
task :precompile_emails do
on roles(:app) do
within release_path do
execute *%w[ bundle exec rails runner -e production 'EmailCampaign.compile_all' ]
end
end
end
end
class EmailCampaign < ApplicationRecord
include HtmlToPlainText
has_many :email_transactions
def send_to_all(test = false, test_quantity = 0)
# BEGIN TEST
if test
# Replicate the same transaction for the same subscription.
# Since it's a test they'll all go to a test email address
test_subs = []
subscription = EmailSubscription.first
test_quantity.times do |n|
test_subs.push(subscription)
end
send_to_subs(test_subs, true, test)
return
end
# END TEST
EmailSubscription.where("level >= ?", self.level).find_in_batches(batch_size: 100) do |subs|
send_to_subs(subs, true, test)
end
end
def send_to_subs(subs, mass_mail = false, test = false)
subs.each do |subscription|
transaction = create_transaction_for_subscription(subscription)
begin
SesMailerJob.perform_later(transaction.id, mass_mail, test)
rescue => e
puts "Error caught in queing SesMailer #{e}"
end
end
end
def create_transaction_for_subscription(subscription)
transaction = EmailTransaction.new
transaction.email_campaign = self
transaction.email_subscription = subscription
transaction.email = subscription.user.email
transaction.save
transaction
end
def send_to_user(user)
if !user.email_subscription
user.email_subscription = EmailSubscription.create_for_user(user)
user.save
else
return if user.email_subscription.is_campaign_muted(self)
return if self.level > user.email_subscription.level
end
send_to_subs([user.email_subscription])
end
def send_to_email(email)
user = User.find_or_create_by(email: email)
send_to_user(user)
end
TEMPLATE_ROOT = "app/views/email_campaigns"
def get_binding(transaction)
binding
end
TOKEN = '@TOKEN'
ERB_TAG = /<%.+?%>/
def precompile
layout_path = "#{TEMPLATE_ROOT}/layout.html.erb"
layout_raw = File.open(layout_path).read
template_path = "#{TEMPLATE_ROOT}/#{self.template}"
template_html = File.open(template_path).read
result = layout_raw.gsub("<%= yield %>", template_html)
# premailer doesnt play well with ERB, so we remove them temporarily
matches = result.scan(ERB_TAG)
tokenized_text = result.dup
matches.each do |match|
tokenized_text.sub!(match, TOKEN)
end
# Ensure assets are precompiled before accessing public/assets
# rails assets:precompile
premailer = Premailer.new(
tokenized_text,
:warn_level => Premailer::Warnings::SAFE,
:with_html_string => true,
:css => [
"public/assets/mailers/style.css",
]
)
premailer.to_plain_text
premailed_tokenized_text = premailer.to_inline_css
premailed_text = premailed_tokenized_text.dup
matches.each do |match|
premailed_text.sub!(TOKEN, match)
end
require 'fileutils'
FileUtils.mkdir_p "#{TEMPLATE_ROOT}/generated"
path = "#{TEMPLATE_ROOT}/generated/#{self.template}"
File.open(path, "w+") do |f|
f.write(premailed_text)
end
end
def get_html_and_plain(transaction)
template_path = "#{TEMPLATE_ROOT}/generated/#{self.template}"
template_html = File.open(template_path).read
rendered_template_html = ERB.new(template_html).result(binding)
# remove all text in between <style></style> tag
cleaned = rendered_template_html.dup.gsub /<style.+style>/m, ''
plain = convert_to_text(cleaned)
rich = rendered_template_html
return rich, plain
end
def self.create_campaign(name, subject, level)
campaign = EmailCampaign.new
campaign.subject = subject
template_name = "#{DateTime.now.strftime('%Y%m%d%H%M%S')}_#{name}.html.erb"
campaign.template = template_name
campaign.level = level
path = "#{TEMPLATE_ROOT}/#{template_name}"
File.open(path, "w+") do |f|
f.write("")
end
campaign.save
end
def self.compile_all
self.all.each { |c| c.precompile }
end
end
class EmailCampaignsController < ApplicationController
# Allows you to preview an email in a dev environment.
def show
@campaign = EmailCampaign.find(params[:id])
transaction = @campaign.email_transactions.last
if !transaction
transaction = EmailTransaction.create({email_campaign: @campaign, email_subscription: EmailSubscription.first})
end
@campaign.precompile
html, plain = @campaign.get_html_and_plain(transaction)
render :html => html.html_safe
end
end
class EmailSubscription < ApplicationRecord
EMAIL_LEVEL_MAX = 3
EMAIL_LEVEL_UNSUBSCRIBED = 0
EMAIL_LEVEL_INVALID = -1
EMAIL_LEVEL_BOUNCES = -2
has_many :email_transactions
belongs_to :user, polymorphic: true
def can_level_up
self.level < EmailSubscription::EMAIL_LEVEL_MAX
end
def can_level_down
self.level > EmailSubscription::EMAIL_LEVEL_UNSUBSCRIBED
end
def less_url
"#{ENV["HOST"]}/email_subscriptions/#{self.token}/less"
end
def more_url
"#{ENV["HOST"]}/email_subscriptions/#{self.token}/more"
end
def unsubscribe_url
"#{ENV["HOST"]}/email_subscriptions/#{self.token}/unsubscribe"
end
def mute_url_for_campaign(campaign)
"#{ENV["HOST"]}/email_subscriptions/#{self.token}/mute/#{campaign.id}"
end
def muted_campaigns
if self.muted_campaigns_json != nil
return JSON.parse(self.muted_campaigns_json)
else
return []
end
end
def mute_campaign(campaign)
muted = self.muted_campaigns
if !muted.include? campaign.id
muted.push(campaign.id)
end
self.muted_campaigns_json = muted.to_json
end
def is_campaign_muted(campaign)
return self.muted_campaigns.include? campaign.id
end
def self.create_for_user(user)
subscription = EmailSubscription.new
subscription.user = user
subscription.level = EmailSubscription::EMAIL_LEVEL_MAX
subscription.token = Digest::SHA256.hexdigest(SecureRandom.random_bytes(32))
subscription.save
subscription
end
end
class EmailSubscriptionsController < ApplicationController
before_action {
@subscription = EmailSubscription.find_by_token(params[:token])
}
def less
@subscription.level = [@subscription.level - 1, EmailSubscription::EMAIL_LEVEL_UNSUBSCRIBED].max
@subscription.save
end
def more
@subscription.level = [@subscription.level + 1, EmailSubscription::EMAIL_LEVEL_MAX].min
@subscription.save
end
def unsubscribe
@subscription.level = EmailSubscription::EMAIL_LEVEL_UNSUBSCRIBED
@subscription.save
end
def mute
campaign = EmailCampaign.find(params[:campaign_id])
@subscription.mute_campaign(campaign)
@subscription.save
end
end
class EmailTransaction < ApplicationRecord
belongs_to :email_campaign
belongs_to :email_subscription
end
gem 'shoryuken'
gem 'aws-sdk-sqs'
gem 'aws-sdk-ses', '~> 1'
gem 'premailer-rails'
<% # app/views/email_campaigns/layout.html.erb %>
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
</head>
<body>
<div class="sn-component">
<div class="panel">
<div class="content">
<%= yield %>
</div>
<div class="footer">
<div class="left">
<% # ActiveJob apparently uses raw values for booleans, so need to check both. %>
<% if transaction.email_campaign.recurring == true || transaction.email_campaign.recurring == 1 %>
<a href="<%= transaction.email_subscription.mute_url_for_campaign(transaction.email_campaign) %>">Mute this email</a>
<% else %>
<% if transaction.email_subscription.can_level_down == true || transaction.email_subscription.can_level_down == 1 %>
<p>
<a href="<%= transaction.email_subscription.less_url %>">Decrease email level</a>
</p>
<% end %>
<p>
<a href="<%= transaction.email_subscription.unsubscribe_url %>">Unsubscribe from all email</a>
</p>
<% end %>
</div>
</div>
</div>
</div>
</body>
</html>
class EmailCampaigns < ActiveRecord::Migration[5.1]
def change
create_table(:email_subscriptions) do |t|
t.integer :user_id
t.string :user_type
t.integer :level, :default => 3
t.string :token
t.text :muted_campaigns_json
t.timestamps
end
create_table(:email_campaigns) do |t|
t.string :name, :unique => true
t.string :subject
t.string :template
t.integer :level
t.boolean :recurring, :default => false
t.timestamps
end
create_table(:email_transactions) do |t|
t.string :email
t.integer :email_campaign_id
t.integer :email_subscription_id
t.boolean :sent, :default => false
t.timestamps
end
add_index :email_subscriptions, [:user_id, :user_type]
User.all.each do |user|
if !user.email_subscription
user.create_email_subscription
end
end
end
end
get "email_subscriptions/:token/more" => "email_subscriptions#more"
get "email_subscriptions/:token/less" => "email_subscriptions#less"
get "email_subscriptions/:token/unsubscribe" => "email_subscriptions#unsubscribe"
get "email_subscriptions/:token/mute/:campaign_id" => "email_subscriptions#mute"
if Rails.env.development?
get "email_campaigns/:id" => "email_campaigns#show"
end
class SesMailerJob < ApplicationJob
queue_as ENV["EMAIL_QUEUE"] ? ENV["EMAIL_QUEUE"] : (Rails.env.production? ? 'mailer' : 'dev_mailer')
require 'aws-sdk-ses'
def perform(transaction_id, mass_mail = false, test = false)
start = Time.now
transaction = EmailTransaction.find(transaction_id)
# SQS may deliver a message more than once. If it's already sent, return.
return if transaction.sent
campaign = transaction.email_campaign
sender = "Standard Notes <hello@standardnotes.org>"
recipient = transaction.email
if test
recipient = "success@simulator.amazonses.com"
end
puts "Sending to recipient #{recipient}"
subject = campaign.subject
htmlbody, textbody = campaign.get_html_and_plain(transaction)
encoding = "UTF-8"
# Create a new SES resource and specify a region
ses = Aws::SES::Client.new
# Try to send the email.
begin
# Provide the contents of the email.
params = {
destination: {
to_addresses: [
recipient,
],
},
message: {
body: {
html: {
charset: encoding,
data: htmlbody,
},
text: {
charset: encoding,
data: textbody,
},
},
subject: {
charset: encoding,
data: subject,
},
},
source: sender
}
resp = ses.send_email(params)
transaction.sent = true
transaction.save
puts "Transaction #{transaction.id} sent!"
rescue Aws::SES::Errors::ServiceError => error
puts "Email not sent. Error message: #{error}"
end
if mass_mail
# We don't want to send more than 1 email per second
finish = Time.now
diff = finish - start
# Ensure that each operation takes at least one second (to achieve rate limiting)
if diff < 1
puts "Transaction #{transaction.id} sleeping for #{1 - diff} seconds"
sleep(1 - diff)
end
puts "Finished sending transaction #{transaction.id} in #{diff} seconds"
end
end
end
# Start shoryuken with `bundle exec shoryuken -q mailer -R -C config/shoryuken.yml`
concurrency: 20
queues:
- [mailer, 1]
class User < ApplicationRecord
has_one :email_subscription, as: :user
def create_email_subscription
s = EmailSubscription.new
s.user = self
s.level = EmailSubscription::EMAIL_LEVEL_MAX
s.token = Digest::SHA256.hexdigest(SecureRandom.random_bytes(32))
s.save
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.