mailkick 0.4.0 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -2,40 +2,50 @@ module Mailkick
2
2
  class SubscriptionsController < ActionController::Base
3
3
  protect_from_forgery with: :exception
4
4
 
5
- before_action :set_email
5
+ before_action :set_subscription
6
6
 
7
7
  def show
8
8
  end
9
9
 
10
10
  def unsubscribe
11
- Mailkick.opt_out(@options)
11
+ subscription.delete_all
12
+
13
+ Mailkick::Legacy.opt_out(legacy_options) if Mailkick::Legacy.opt_outs?
14
+
12
15
  redirect_to subscription_path(params[:id])
13
16
  end
14
17
 
15
18
  def subscribe
16
- Mailkick.opt_in(@options)
19
+ subscription.first_or_create!
20
+
21
+ Mailkick::Legacy.opt_in(legacy_options) if Mailkick::Legacy.opt_outs?
22
+
17
23
  redirect_to subscription_path(params[:id])
18
24
  end
19
25
 
20
26
  protected
21
27
 
22
- def set_email
23
- @email, user_id, user_type, @list = Mailkick.message_verifier.verify(params[:id])
24
- if user_type
25
- # on the unprobabilistic chance user_type is compromised, not much damage
26
- @user = user_type.constantize.find(user_id)
27
- end
28
- @options = {
29
- email: @email,
30
- user: @user,
31
- list: @list
32
- }
28
+ def set_subscription
29
+ @email, @subscriber_id, @subscriber_type, @list = Mailkick.message_verifier.verify(params[:id])
33
30
  rescue ActiveSupport::MessageVerifier::InvalidSignature
34
31
  render plain: "Subscription not found", status: :bad_request
35
32
  end
36
33
 
34
+ def subscription
35
+ Mailkick::Subscription.where(
36
+ subscriber_id: @subscriber_id,
37
+ subscriber_type: @subscriber_type,
38
+ list: @list
39
+ )
40
+ end
41
+
42
+ def subscribed?
43
+ subscription.exists?
44
+ end
45
+ helper_method :subscribed?
46
+
37
47
  def opted_out?
38
- Mailkick.opted_out?(@options)
48
+ !subscribed?
39
49
  end
40
50
  helper_method :opted_out?
41
51
 
@@ -48,5 +58,17 @@ module Mailkick
48
58
  unsubscribe_subscription_path(params[:id])
49
59
  end
50
60
  helper_method :unsubscribe_url
61
+
62
+ def legacy_options
63
+ if @subscriber_type
64
+ # on the unprobabilistic chance subscriber_type is compromised, not much damage
65
+ user = @subscriber_type.constantize.find(@subscriber_id)
66
+ end
67
+ {
68
+ email: @email,
69
+ user: user,
70
+ list: @list
71
+ }
72
+ end
51
73
  end
52
74
  end
@@ -0,0 +1,9 @@
1
+ module Mailkick
2
+ class Subscription < ActiveRecord::Base
3
+ self.table_name = "mailkick_subscriptions"
4
+
5
+ belongs_to :subscriber, polymorphic: true
6
+
7
+ validates :list, presence: true
8
+ end
9
+ end
@@ -24,7 +24,7 @@
24
24
  </head>
25
25
  <body>
26
26
  <div class="container">
27
- <% if opted_out? %>
27
+ <% if !subscribed? %>
28
28
  <p>You are unsubscribed.</p>
29
29
  <p><%= link_to "Resubscribe", subscribe_url %></p>
30
30
  <% else %>
@@ -7,7 +7,7 @@ module Mailkick
7
7
  source_root File.join(__dir__, "templates")
8
8
 
9
9
  def copy_migration
10
- migration_template "install.rb", "db/migrate/install_mailkick.rb", migration_version: migration_version
10
+ migration_template "install.rb", "db/migrate/create_mailkick_subscriptions.rb", migration_version: migration_version
11
11
  end
12
12
 
13
13
  def migration_version
@@ -1,14 +1,11 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
- create_table :mailkick_opt_outs do |t|
4
- t.string :email
5
- t.references :user, polymorphic: true
6
- t.boolean :active, null: false, default: true
7
- t.string :reason
3
+ create_table :mailkick_subscriptions do |t|
4
+ t.references :subscriber, polymorphic: true, index: false
8
5
  t.string :list
9
6
  t.timestamps
10
7
  end
11
8
 
12
- add_index :mailkick_opt_outs, :email
9
+ add_index :mailkick_subscriptions, [:subscriber_type, :subscriber_id, :list], unique: true, name: "index_mailkick_subscriptions_on_subscriber_and_list"
13
10
  end
14
11
  end
data/lib/mailkick.rb CHANGED
@@ -1,24 +1,31 @@
1
1
  # dependencies
2
- require "set"
3
2
  require "active_support"
4
3
 
4
+ # stdlib
5
+ require "set"
6
+
5
7
  # modules
8
+ require "mailkick/legacy"
6
9
  require "mailkick/model"
7
10
  require "mailkick/service"
11
+ require "mailkick/service/aws_ses"
8
12
  require "mailkick/service/mailchimp"
9
13
  require "mailkick/service/mailgun"
10
14
  require "mailkick/service/mandrill"
11
15
  require "mailkick/service/sendgrid"
16
+ require "mailkick/service/sendgrid_v2"
17
+ require "mailkick/service/postmark"
18
+ require "mailkick/url_helper"
12
19
  require "mailkick/version"
13
20
 
14
21
  # integrations
15
22
  require "mailkick/engine" if defined?(Rails)
16
23
 
17
24
  module Mailkick
18
- mattr_accessor :services, :user_method, :secret_token, :mount
25
+ mattr_accessor :services, :secret_token, :mount, :process_opt_outs_method
19
26
  self.services = []
20
- self.user_method = ->(email) { User.where(email: email).first rescue nil }
21
27
  self.mount = true
28
+ self.process_opt_outs_method = ->(_) { raise "process_opt_outs_method not defined" }
22
29
 
23
30
  def self.fetch_opt_outs
24
31
  services.each(&:fetch_opt_outs)
@@ -30,79 +37,22 @@ module Mailkick
30
37
  end
31
38
  end
32
39
 
33
- def self.opted_out?(options)
34
- opt_outs(options).any?
35
- end
36
-
37
- def self.opt_out(options)
38
- unless opted_out?(options)
39
- time = options[:time] || Time.now
40
- Mailkick::OptOut.create! do |o|
41
- o.email = options[:email]
42
- o.user = options[:user]
43
- o.reason = options[:reason] || "unsubscribe"
44
- o.list = options[:list]
45
- o.created_at = time
46
- o.updated_at = time
47
- end
48
- end
49
- true
50
- end
51
-
52
- def self.opt_in(options)
53
- opt_outs(options).each do |opt_out|
54
- opt_out.active = false
55
- opt_out.save!
56
- end
57
- true
58
- end
59
-
60
- def self.opt_outs(options = {})
61
- relation = Mailkick::OptOut.where(active: true)
62
-
63
- parts = []
64
- binds = []
65
- if (email = options[:email])
66
- parts << "email = ?"
67
- binds << email
68
- end
69
- if (user = options[:user])
70
- parts << "(user_id = ? and user_type = ?)"
71
- binds.concat [user.id, user.class.name]
72
- end
73
- relation = relation.where(parts.join(" OR "), *binds) if parts.any?
74
-
75
- relation =
76
- if options[:list]
77
- relation.where("list IS NULL OR list = ?", options[:list])
78
- else
79
- relation.where("list IS NULL")
80
- end
81
-
82
- relation
83
- end
84
-
85
- def self.opted_out_emails(options = {})
86
- Set.new(opt_outs(options).where("email IS NOT NULL").uniq.pluck(:email))
87
- end
88
-
89
- # does not take into account emails
90
- def self.opted_out_users(options = {})
91
- Set.new(opt_outs(options).where("user_id IS NOT NULL").map(&:user))
92
- end
93
-
94
40
  def self.message_verifier
95
41
  @message_verifier ||= ActiveSupport::MessageVerifier.new(Mailkick.secret_token)
96
42
  end
97
43
 
98
- def self.generate_token(email, user: nil, list: nil)
99
- raise ArgumentError, "Missing email" unless email
44
+ def self.generate_token(subscriber, list)
45
+ raise ArgumentError, "Missing subscriber" unless subscriber
46
+ raise ArgumentError, "Missing list" unless list.present?
100
47
 
101
- user ||= Mailkick.user_method.call(email) if Mailkick.user_method
102
- message_verifier.generate([email, user.try(:id), user.try(:class).try(:name), list])
48
+ message_verifier.generate([nil, subscriber.id, subscriber.class.name, list])
103
49
  end
104
50
  end
105
51
 
52
+ ActiveSupport.on_load :action_mailer do
53
+ helper Mailkick::UrlHelper
54
+ end
55
+
106
56
  ActiveSupport.on_load(:active_record) do
107
57
  extend Mailkick::Model
108
58
  end
@@ -17,10 +17,6 @@ module Mailkick
17
17
 
18
18
  creds.respond_to?(:secret_key_base) ? creds.secret_key_base : creds.secret_token
19
19
  end
20
-
21
- ActiveSupport.on_load :action_mailer do
22
- helper Mailkick::UrlHelper
23
- end
24
20
  end
25
21
  end
26
22
  end
@@ -0,0 +1,70 @@
1
+ module Mailkick
2
+ module Legacy
3
+ # checks for table as long as it exists
4
+ def self.opt_outs?
5
+ unless defined?(@opt_outs) && @opt_outs == false
6
+ @opt_outs = ActiveRecord::Base.connection.table_exists?("mailkick_opt_outs")
7
+ end
8
+ @opt_outs
9
+ end
10
+
11
+ def self.opted_out?(options)
12
+ opt_outs(options).any?
13
+ end
14
+
15
+ def self.opt_out(options)
16
+ unless opted_out?(options)
17
+ time = options[:time] || Time.now
18
+ Mailkick::OptOut.create! do |o|
19
+ o.email = options[:email]
20
+ o.user = options[:user]
21
+ o.reason = options[:reason] || "unsubscribe"
22
+ o.list = options[:list]
23
+ o.created_at = time
24
+ o.updated_at = time
25
+ end
26
+ end
27
+ true
28
+ end
29
+
30
+ def self.opt_in(options)
31
+ opt_outs(options).each do |opt_out|
32
+ opt_out.active = false
33
+ opt_out.save!
34
+ end
35
+ true
36
+ end
37
+
38
+ def self.opt_outs(options = {})
39
+ relation = Mailkick::OptOut.where(active: true)
40
+
41
+ contact_relation = Mailkick::OptOut.none
42
+ if (email = options[:email])
43
+ contact_relation = contact_relation.or(Mailkick::OptOut.where(email: email))
44
+ end
45
+ if (user = options[:user])
46
+ contact_relation = contact_relation.or(
47
+ Mailkick::OptOut.where("user_id = ? AND user_type = ?", user.id, user.class.name)
48
+ )
49
+ end
50
+ relation = relation.merge(contact_relation) if email || user
51
+
52
+ relation =
53
+ if options[:list]
54
+ relation.where("list IS NULL OR list = ?", options[:list])
55
+ else
56
+ relation.where("list IS NULL")
57
+ end
58
+
59
+ relation
60
+ end
61
+
62
+ def self.opted_out_emails(options = {})
63
+ Set.new(opt_outs(options).where.not(email: nil).distinct.pluck(:email))
64
+ end
65
+
66
+ def self.opted_out_users(options = {})
67
+ Set.new(opt_outs(options).where.not(user_id: nil).map(&:user))
68
+ end
69
+ end
70
+ end
@@ -1,33 +1,22 @@
1
1
  module Mailkick
2
2
  module Model
3
- def mailkick_user(opts = {})
4
- email_key = opts[:email_key] || :email
3
+ def has_subscriptions
5
4
  class_eval do
6
- scope :opted_out, lambda { |options = {}|
7
- binds = [name, true]
8
- if options[:list]
9
- query = "(mailkick_opt_outs.list IS NULL OR mailkick_opt_outs.list = ?)"
10
- binds << options[:list]
11
- else
12
- query = "mailkick_opt_outs.list IS NULL"
13
- end
14
- where("#{options[:not] ? 'NOT ' : ''}EXISTS(SELECT * FROM mailkick_opt_outs WHERE (#{table_name}.#{email_key} = mailkick_opt_outs.email OR (#{table_name}.#{primary_key} = mailkick_opt_outs.user_id AND mailkick_opt_outs.user_type = ?)) AND mailkick_opt_outs.active = ? AND #{query})", *binds)
15
- }
5
+ has_many :mailkick_subscriptions, class_name: "Mailkick::Subscription", as: :subscriber
6
+ scope :subscribed, -> (list) { joins(:mailkick_subscriptions).where(mailkick_subscriptions: {list: list}) }
16
7
 
17
- scope :not_opted_out, lambda { |options = {}|
18
- opted_out(options.merge(not: true))
19
- }
20
-
21
- define_method :opted_out? do |options = {}|
22
- Mailkick.opted_out?({email: send(email_key), user: self}.merge(options))
8
+ def subscribe(list)
9
+ mailkick_subscriptions.where(list: list).first_or_create!
10
+ nil
23
11
  end
24
12
 
25
- define_method :opt_out do |options = {}|
26
- Mailkick.opt_out({email: send(email_key), user: self}.merge(options))
13
+ def unsubscribe(list)
14
+ mailkick_subscriptions.where(list: list).delete_all
15
+ nil
27
16
  end
28
17
 
29
- define_method :opt_in do |options = {}|
30
- Mailkick.opt_in({email: send(email_key), user: self}.merge(options))
18
+ def subscribed?(list)
19
+ mailkick_subscriptions.where(list: list).exists?
31
20
  end
32
21
  end
33
22
  end
@@ -1,22 +1,7 @@
1
1
  module Mailkick
2
2
  class Service
3
3
  def fetch_opt_outs
4
- opt_outs.each do |api_data|
5
- email = api_data[:email]
6
- time = api_data[:time]
7
-
8
- opt_out = Mailkick::OptOut.where(email: email).order("updated_at desc").first
9
- if !opt_out || (time > opt_out.updated_at && !opt_out.active)
10
- Mailkick.opt_out(
11
- email: email,
12
- user: Mailkick.user_method ? Mailkick.user_method.call(email) : nil,
13
- reason: api_data[:reason],
14
- time: time
15
- )
16
- end
17
- end
18
-
19
- true
4
+ Mailkick.process_opt_outs_method.call(opt_outs)
20
5
  end
21
6
  end
22
7
  end
@@ -0,0 +1,47 @@
1
+ # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SESV2/Client.html
2
+
3
+ module Mailkick
4
+ class Service
5
+ class AwsSes < Mailkick::Service
6
+ REASONS_MAP = {
7
+ "BOUNCE" => "bounce",
8
+ "COMPLAINT" => "spam"
9
+ }
10
+
11
+ def initialize(options = {})
12
+ @options = options
13
+ end
14
+
15
+ def opt_outs
16
+ response = client.list_suppressed_destinations({
17
+ reasons: ["BOUNCE", "COMPLAINT"],
18
+ # TODO make configurable
19
+ start_date: Time.now - (86400 * 365),
20
+ end_date: Time.now
21
+ })
22
+
23
+ opt_outs = []
24
+ response.each do |page|
25
+ page.suppressed_destination_summaries.each do |record|
26
+ opt_outs << {
27
+ email: record.email_address,
28
+ time: record.last_update_time,
29
+ reason: REASONS_MAP[record.reason]
30
+ }
31
+ end
32
+ end
33
+ opt_outs
34
+ end
35
+
36
+ def self.discoverable?
37
+ !!defined?(::Aws::SESV2::Client)
38
+ end
39
+
40
+ private
41
+
42
+ def client
43
+ @client ||= ::Aws::SESV2::Client.new
44
+ end
45
+ end
46
+ end
47
+ end