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"