mailkick 0.4.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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