diff --git a/Guardfile b/Guardfile index 80da15849..915243a85 100644 --- a/Guardfile +++ b/Guardfile @@ -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 } diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 87c64ae7d..76e00ae68 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -154,6 +154,7 @@ class UsersController < ApplicationController website_url summary email_newsletter + email_digest_periodic email_membership_newsletter email_comment_notifications email_mention_notifications diff --git a/app/labor/email_digest.rb b/app/labor/email_digest.rb new file mode 100644 index 000000000..16232123a --- /dev/null +++ b/app/labor/email_digest.rb @@ -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 diff --git a/app/labor/email_logic.rb b/app/labor/email_logic.rb new file mode 100644 index 000000000..e7883c10e --- /dev/null +++ b/app/labor/email_logic.rb @@ -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 diff --git a/app/mailers/digest_mailer.rb b/app/mailers/digest_mailer.rb new file mode 100644 index 000000000..4c410cc20 --- /dev/null +++ b/app/mailers/digest_mailer.rb @@ -0,0 +1,5 @@ +class DigestMailer < ApplicationMailer + def daily_digest(recipient) + @recipient = recipient + end +end diff --git a/app/views/users/_notifications.html.erb b/app/views/users/_notifications.html.erb index 3c4c41bda..d3d98e779 100644 --- a/app/views/users/_notifications.html.erb +++ b/app/views/users/_notifications.html.erb @@ -3,12 +3,16 @@
<%= f.check_box :email_newsletter %> - <%= f.label :email_newsletter, "Send me periodic newsletter emails" %> + <%= f.label :email_newsletter, "Send me weekly newsletter emails" %> +
+
+ <%= f.check_box :email_digest_periodic %> + <%= f.label :email_digest_periodic, "Send me a periodic digest of top posts from my tags" %>
<% if current_user.a_sustaining_member? %>
<%= 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" %>
<% end %>
diff --git a/config/initializers/_env_checker.rb b/config/initializers/_env_checker.rb index ff9bcb973..7df313706 100644 --- a/config/initializers/_env_checker.rb +++ b/config/initializers/_env_checker.rb @@ -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", diff --git a/db/migrate/20180321170500_add_email_digest_periodic_to_user.rb b/db/migrate/20180321170500_add_email_digest_periodic_to_user.rb new file mode 100644 index 000000000..2d0dc4805 --- /dev/null +++ b/db/migrate/20180321170500_add_email_digest_periodic_to_user.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 8ab364a22..a73b5f201 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 3faf5f100..1ddc75c69 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -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) } diff --git a/spec/labor/email_digest_spec.rb b/spec/labor/email_digest_spec.rb new file mode 100644 index 000000000..1bbe3ed62 --- /dev/null +++ b/spec/labor/email_digest_spec.rb @@ -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 diff --git a/spec/labor/email_logic_spec.rb b/spec/labor/email_logic_spec.rb new file mode 100644 index 000000000..448168702 --- /dev/null +++ b/spec/labor/email_logic_spec.rb @@ -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 diff --git a/yarn.lock b/yarn.lock index 60e4b0f04..6593da91b 100644 --- a/yarn.lock +++ b/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"