Implement periodic email digest (#123)
* Create EmailDigest WIP * Add email_digest_periodic to User model * Add email digest setting to notification * Draft thoughts (?) WIP * Implement EmailDigest's features * Remove hard-coded email digest vars * Create spec for EmailDigest * Fix something * Temp work * Run migration & Yarn * Move email logic out of EmailDigest * Improve email logic * Improve EmailLogic'specs * Update copy * Change positive_reactions_count limit For non-followed articles we should have a higher limit. So this change modifies that.
This commit is contained in:
parent
c1b0d08c59
commit
9d32af7b6c
13 changed files with 289 additions and 14 deletions
|
|
@ -36,7 +36,7 @@ rspec_options = {
|
|||
# fast but no coverage
|
||||
cmd: "bin/spring rspec -p",
|
||||
#############################
|
||||
failed_mode: :none,
|
||||
failed_mode: :focus,
|
||||
bundler_env: :clean_env
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ class UsersController < ApplicationController
|
|||
website_url
|
||||
summary
|
||||
email_newsletter
|
||||
email_digest_periodic
|
||||
email_membership_newsletter
|
||||
email_comment_notifications
|
||||
email_mention_notifications
|
||||
|
|
|
|||
29
app/labor/email_digest.rb
Normal file
29
app/labor/email_digest.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Usecase would be
|
||||
# EmailDigest.send_periodic_digest_email
|
||||
# OR
|
||||
# EmailDigets.send_periodic_digest_email(Users.first(4))
|
||||
|
||||
class EmailDigest
|
||||
def self.send_periodic_digest_email(users = [])
|
||||
new(users).send_periodic_digest_email
|
||||
end
|
||||
|
||||
def initialize(users = [])
|
||||
@users = users.empty? ? get_users : users
|
||||
end
|
||||
|
||||
def send_periodic_digest_email
|
||||
@users.each do |user|
|
||||
user_email_heuristic = EmailLogic.new(user).analyze
|
||||
next unless user_email_heuristic.should_receive_email?
|
||||
articles = user_email_heuristic.articles_to_send
|
||||
DigestMailer.daily_digest(user, articles).deliver
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_users
|
||||
User.where(email_digest_periodic: true)
|
||||
end
|
||||
end
|
||||
92
app/labor/email_logic.rb
Normal file
92
app/labor/email_logic.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
class EmailLogic
|
||||
attr_reader :open_percentage, :last_email_sent_at,
|
||||
:days_until_next_email, :articles_to_send
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
@open_percentage = nil
|
||||
@ready_to_receive_email = nil
|
||||
@last_email_sent_at = nil
|
||||
@days_until_next_email = nil
|
||||
@articles_to_send = []
|
||||
end
|
||||
|
||||
def analyze
|
||||
@last_email_sent_at = get_last_digest_email_user_recieved
|
||||
@open_percentage = get_open_rate
|
||||
@days_until_next_email = get_days_until_next_email
|
||||
@ready_to_receive_email = get_user_readiness
|
||||
if @ready_to_receive_email
|
||||
@articles_to_send = get_articles_to_send
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
def should_receive_email?
|
||||
@ready_to_receive_email
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_articles_to_send
|
||||
fresh_date = get_fresh_date
|
||||
articles = if user_has_followings?
|
||||
@user.followed_articles.
|
||||
where("created_at > ?", fresh_date).
|
||||
where("positive_reactions_count > ?", 15).
|
||||
order("positive_reactions_count DESC").
|
||||
limit(6)
|
||||
else
|
||||
Article.
|
||||
where("positive_reactions_count > ?", 30).
|
||||
order("positive_reactions_count DESC").
|
||||
limit(6)
|
||||
end
|
||||
if articles.length < 3
|
||||
@ready_to_receive_email = false
|
||||
end
|
||||
articles
|
||||
end
|
||||
|
||||
def get_days_until_next_email
|
||||
# Relies on hyperbolic tangent function to model the frequency of the digest email
|
||||
max_day = ENV["PERIODIC_EMAIL_DIGEST_MAX"].to_i
|
||||
min_day = ENV["PERIODIC_EMAIL_DIGEST_MIN"].to_i
|
||||
result = max_day * (1 - Math.tanh(2 * @open_percentage))
|
||||
result = result.round
|
||||
result < min_day ? min_day : result
|
||||
end
|
||||
|
||||
def get_open_rate
|
||||
past_sent_emails = @user.email_messages.where(mailer: "DigestMailer#daily_digest").limit(10)
|
||||
|
||||
# Will stick with 50% open rate if @user has no/not-enough email digest history
|
||||
return 0.5 if past_sent_emails.length < 10
|
||||
|
||||
past_sent_emails_count = past_sent_emails.count
|
||||
past_opened_emails_count = past_sent_emails.where("opened_at IS NOT NULL").count
|
||||
past_opened_emails_count / past_sent_emails_count
|
||||
end
|
||||
|
||||
def get_user_readiness
|
||||
return true unless @last_email_sent_at
|
||||
# Has it been atleast x days since @user receive an email?
|
||||
(Time.now.utc - @last_email_sent_at) >= @days_until_next_email.days.to_i
|
||||
end
|
||||
|
||||
def get_last_digest_email_user_recieved
|
||||
@user.email_messages.where(mailer: "DigestMailer#daily_digest").last&.sent_at
|
||||
end
|
||||
|
||||
def get_fresh_date
|
||||
a_month_ago = 1.month.ago.utc
|
||||
return a_month_ago unless @last_email_sent_at
|
||||
a_month_ago > @last_email_sent_at ? a_month_ago : @last_email_sent_at
|
||||
end
|
||||
|
||||
def user_has_followings?
|
||||
following_users = @user.cached_following_users_ids
|
||||
following_tags = @user.cached_followed_tag_names
|
||||
!following_users.empty? || !following_tags.empty?
|
||||
end
|
||||
end
|
||||
5
app/mailers/digest_mailer.rb
Normal file
5
app/mailers/digest_mailer.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class DigestMailer < ApplicationMailer
|
||||
def daily_digest(recipient)
|
||||
@recipient = recipient
|
||||
end
|
||||
end
|
||||
|
|
@ -3,12 +3,16 @@
|
|||
<div class="checkbox-field">
|
||||
<div class="sub-field">
|
||||
<%= f.check_box :email_newsletter %>
|
||||
<%= f.label :email_newsletter, "Send me periodic newsletter emails" %>
|
||||
<%= f.label :email_newsletter, "Send me weekly newsletter emails" %>
|
||||
</div>
|
||||
<div class="sub-field">
|
||||
<%= f.check_box :email_digest_periodic %>
|
||||
<%= f.label :email_digest_periodic, "Send me a periodic digest of top posts from my tags" %>
|
||||
</div>
|
||||
<% if current_user.a_sustaining_member? %>
|
||||
<div class="sub-field">
|
||||
<%= f.check_box :email_membership_newsletter %>
|
||||
<%= f.label :email_membership_newsletter, "Send me membership newsletter emails" %>
|
||||
<%= f.label :email_membership_newsletter, "Send me sustaining membership newsletter emails" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="sub-field">
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ keys = [
|
|||
"KEEN_WRITE_KEY",
|
||||
"MAILCHIMP_API_KEY",
|
||||
"MAILCHIMP_NEWSLETTER_ID",
|
||||
"PERIODIC_EMAIL_DIGEST_MAX",
|
||||
"PERIODIC_EMAIL_DIGEST_MIN",
|
||||
"RECAPTCHA_SECRET",
|
||||
"RECAPTCHA_SITE",
|
||||
"SERVICE_TIMEOUT",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
class AddEmailDigestPeriodicToUser < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_column :users, :email_digest_periodic, :boolean, default: true, null: false
|
||||
end
|
||||
end
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 20180316174324) do
|
||||
ActiveRecord::Schema.define(version: 20180321170500) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
|
@ -618,6 +618,7 @@ ActiveRecord::Schema.define(version: 20180316174324) do
|
|||
t.string "website_url"
|
||||
t.datetime "workshop_expiration"
|
||||
t.boolean "permit_adjacent_sponsors", default: true
|
||||
t.boolean "email_digest_periodic", default: true, null: false
|
||||
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
|
||||
t.index ["language_settings"], name: "index_users_on_language_settings", using: :gin
|
||||
t.index ["organization_id"], name: "index_users_on_organization_id"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ FactoryBot.define do
|
|||
confirmed_at { Time.now }
|
||||
saw_onboarding { true }
|
||||
signup_cta_variant { "navbar_basic" }
|
||||
email_digest_periodic { false }
|
||||
|
||||
trait :super_admin do
|
||||
after(:build) { |user| user.add_role(:super_admin) }
|
||||
|
|
|
|||
37
spec/labor/email_digest_spec.rb
Normal file
37
spec/labor/email_digest_spec.rb
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
require "rails_helper"
|
||||
|
||||
class FakeDelegator < ActionMailer::MessageDelivery
|
||||
# TODO: we should replace all usage of .deliver to .deliver_now
|
||||
def deliver(*args); super end
|
||||
end
|
||||
|
||||
RSpec.describe EmailDigest do
|
||||
let(:user) { create(:user, email_digest_periodic: true) }
|
||||
let(:author) { create(:user) }
|
||||
let(:mock_delegator) { instance_double("FakeDelegator") }
|
||||
|
||||
before do
|
||||
allow(DigestMailer).to receive(:daily_digest) { mock_delegator }
|
||||
allow(mock_delegator).to receive(:deliver).and_return(true)
|
||||
Delayed::Worker.delay_jobs = false
|
||||
user
|
||||
end
|
||||
|
||||
after do
|
||||
Delayed::Worker.delay_jobs = true
|
||||
end
|
||||
|
||||
describe "::send_daily_digest" do
|
||||
context "when there's article to be sent" do
|
||||
before { user.follow(author) }
|
||||
|
||||
it "send digest email when there's atleast 3 hot articles" do
|
||||
3.times { create(:article, user_id: author.id, positive_reactions_count: 20) }
|
||||
described_class.send_periodic_digest_email
|
||||
expect(DigestMailer).to have_received(:daily_digest).with(
|
||||
user, [instance_of(Article), instance_of(Article), instance_of(Article)]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
77
spec/labor/email_logic_spec.rb
Normal file
77
spec/labor/email_logic_spec.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe EmailLogic do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
# TODO: improve this test suite, and improve it's speed
|
||||
|
||||
describe "#analyze" do
|
||||
context "when user is brand new with no-follow" do
|
||||
it "returns 0.5 for open_percentage" do
|
||||
author = create(:user)
|
||||
user.follow(author)
|
||||
3.times { create(:article, user_id: author.id, positive_reactions_count: 20) }
|
||||
h = described_class.new(user).analyze
|
||||
expect(h.open_percentage).to eq(0.5)
|
||||
end
|
||||
|
||||
it "provides top 3 articles" do
|
||||
3.times { create(:article, positive_reactions_count: 20) }
|
||||
h = described_class.new(user).analyze
|
||||
expect(h.articles_to_send.length).to eq(3)
|
||||
end
|
||||
|
||||
it "marks as not ready if there isn't atleast 3 articles" do
|
||||
2.times { create(:article, positive_reactions_count: 20) }
|
||||
h = described_class.new(user).analyze
|
||||
expect(h.should_receive_email?).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
context "when a user's open_percentage is low " do
|
||||
before do
|
||||
author = create(:user)
|
||||
user.follow(author)
|
||||
3.times { create(:article, user_id: author.id, positive_reactions_count: 20) }
|
||||
10.times do
|
||||
Ahoy::Message.create(mailer: "DigestMailer#daily_digest",
|
||||
user_id: user.id, sent_at: Time.now.utc)
|
||||
end
|
||||
end
|
||||
|
||||
it "will not send email when user shouldn't receive any" do
|
||||
h = described_class.new(user).analyze
|
||||
expect(h.should_receive_email?).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
context "when a user's open_percentage is high" do
|
||||
before do
|
||||
10.times do
|
||||
Ahoy::Message.create(mailer: "DigestMailer#daily_digest", user_id: user.id,
|
||||
sent_at: Time.now.utc, opened_at: Time.now.utc)
|
||||
author = create(:user)
|
||||
user.follow(author)
|
||||
3.times { create(:article, user_id: author.id, positive_reactions_count: 20) }
|
||||
end
|
||||
end
|
||||
|
||||
it "evaluates that user is ready to recieve an email" do
|
||||
Timecop.freeze(Date.today + 3) do
|
||||
h = described_class.new(user).analyze
|
||||
expect(h.should_receive_email?).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#should_receive_email?" do
|
||||
it "refelcts @ready_to_receive_email" do
|
||||
author = create(:user)
|
||||
user.follow(author)
|
||||
3.times { create(:article, user_id: author.id, positive_reactions_count: 20) }
|
||||
h = described_class.new(user).analyze
|
||||
expect(h.should_receive_email?).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
41
yarn.lock
41
yarn.lock
|
|
@ -4184,6 +4184,12 @@ ignore@^3.3.3:
|
|||
version "3.3.5"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.5.tgz#c4e715455f6073a8d7e5dae72d2fc9d71663dba6"
|
||||
|
||||
immutability-helper@^2.1.2:
|
||||
version "2.6.6"
|
||||
resolved "https://registry.yarnpkg.com/immutability-helper/-/immutability-helper-2.6.6.tgz#9b384c240d65257133c155086e16f678ca563b05"
|
||||
dependencies:
|
||||
invariant "^2.2.0"
|
||||
|
||||
immutable@^3.8.1:
|
||||
version "3.8.2"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
|
||||
|
|
@ -4279,6 +4285,12 @@ interpret@^1.0.0:
|
|||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.4.tgz#820cdd588b868ffb191a809506d6c9c8f212b1b0"
|
||||
|
||||
invariant@^2.2.0:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||
dependencies:
|
||||
loose-envify "^1.0.0"
|
||||
|
||||
invariant@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360"
|
||||
|
|
@ -6684,6 +6696,16 @@ postcss@^6.0.17:
|
|||
source-map "^0.6.1"
|
||||
supports-color "^5.3.0"
|
||||
|
||||
preact-compat@^3.18.0:
|
||||
version "3.18.0"
|
||||
resolved "https://registry.yarnpkg.com/preact-compat/-/preact-compat-3.18.0.tgz#ca430cc1f67193fb9feaf7c510832957b2ebe701"
|
||||
dependencies:
|
||||
immutability-helper "^2.1.2"
|
||||
preact-render-to-string "^3.6.0"
|
||||
preact-transition-group "^1.1.0"
|
||||
prop-types "^15.5.8"
|
||||
standalone-react-addons-pure-render-mixin "^0.1.1"
|
||||
|
||||
preact-render-spy@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/preact-render-spy/-/preact-render-spy-1.2.1.tgz#5f4101c8f15f39162db7d274230935cfb3f07f5c"
|
||||
|
|
@ -6696,12 +6718,16 @@ preact-render-to-json@^3.6.6:
|
|||
version "3.6.6"
|
||||
resolved "https://registry.yarnpkg.com/preact-render-to-json/-/preact-render-to-json-3.6.6.tgz#f67f48581912ac53fc9f4873bc6d7ce342f71c20"
|
||||
|
||||
preact-render-to-string@^3.6.3:
|
||||
preact-render-to-string@^3.6.0, preact-render-to-string@^3.6.3:
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-3.7.0.tgz#7db4177454bc01395e0d01d6ac07bc5e838e31ee"
|
||||
dependencies:
|
||||
pretty-format "^3.5.1"
|
||||
|
||||
preact-transition-group@^1.1.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/preact-transition-group/-/preact-transition-group-1.1.1.tgz#f0a49327ea515ece34ea2be864c4a7d29e5d6e10"
|
||||
|
||||
preact@^8.2.5:
|
||||
version "8.2.5"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-8.2.5.tgz#cbfa3962a8012768159f6d01d46f9c1eb3213c0a"
|
||||
|
|
@ -7040,15 +7066,6 @@ react-treebeard@^2.1.0:
|
|||
shallowequal "^0.2.2"
|
||||
velocity-react "^1.3.1"
|
||||
|
||||
react@^16.2.0:
|
||||
version "16.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
|
||||
dependencies:
|
||||
fbjs "^0.8.16"
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.0"
|
||||
|
||||
read-cache@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
|
||||
|
|
@ -7903,6 +7920,10 @@ stack-utils@^1.0.1:
|
|||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.1.tgz#d4f33ab54e8e38778b0ca5cfd3b3afb12db68620"
|
||||
|
||||
standalone-react-addons-pure-render-mixin@^0.1.1:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/standalone-react-addons-pure-render-mixin/-/standalone-react-addons-pure-render-mixin-0.1.1.tgz#3c7409f4c79c40de9ac72c616cf679a994f37551"
|
||||
|
||||
"statuses@>= 1.3.1 < 2", statuses@~1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue