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:
Mac Siri 2018-03-27 13:29:08 -04:00 committed by Ben Halpern
parent c1b0d08c59
commit 9d32af7b6c
13 changed files with 289 additions and 14 deletions

View file

@ -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
}

View file

@ -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
View 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
View 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

View file

@ -0,0 +1,5 @@
class DigestMailer < ApplicationMailer
def daily_digest(recipient)
@recipient = recipient
end
end

View file

@ -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">

View file

@ -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",

View file

@ -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

View file

@ -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"

View file

@ -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) }

View 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

View 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

View file

@ -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"