forked from forem/forem
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement periodic email digest (forem#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.
- Loading branch information
1 parent
c1b0d08
commit 9d32af7
Showing
13 changed files
with
289 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
class DigestMailer < ApplicationMailer | ||
def daily_digest(recipient) | ||
@recipient = recipient | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
5 changes: 5 additions & 0 deletions
5
db/migrate/20180321170500_add_email_digest_periodic_to_user.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Oops, something went wrong.