actionmailbox 6.0.0.beta1

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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +13 -0
  5. data/app/controllers/action_mailbox/base_controller.rb +38 -0
  6. data/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb +54 -0
  7. data/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +103 -0
  8. data/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +82 -0
  9. data/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb +62 -0
  10. data/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb +65 -0
  11. data/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb +54 -0
  12. data/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb +34 -0
  13. data/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb +19 -0
  14. data/app/controllers/rails/conductor/base_controller.rb +14 -0
  15. data/app/jobs/action_mailbox/incineration_job.rb +22 -0
  16. data/app/jobs/action_mailbox/routing_job.rb +13 -0
  17. data/app/models/action_mailbox/inbound_email.rb +49 -0
  18. data/app/models/action_mailbox/inbound_email/incineratable.rb +20 -0
  19. data/app/models/action_mailbox/inbound_email/incineratable/incineration.rb +26 -0
  20. data/app/models/action_mailbox/inbound_email/message_id.rb +38 -0
  21. data/app/models/action_mailbox/inbound_email/routable.rb +24 -0
  22. data/app/views/layouts/rails/conductor.html.erb +7 -0
  23. data/app/views/rails/conductor/action_mailbox/inbound_emails/index.html.erb +15 -0
  24. data/app/views/rails/conductor/action_mailbox/inbound_emails/new.html.erb +47 -0
  25. data/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb +15 -0
  26. data/config/routes.rb +20 -0
  27. data/db/migrate/20180917164000_create_action_mailbox_tables.rb +17 -0
  28. data/lib/action_mailbox.rb +16 -0
  29. data/lib/action_mailbox/base.rb +118 -0
  30. data/lib/action_mailbox/callbacks.rb +34 -0
  31. data/lib/action_mailbox/engine.rb +42 -0
  32. data/lib/action_mailbox/gem_version.rb +17 -0
  33. data/lib/action_mailbox/mail_ext.rb +6 -0
  34. data/lib/action_mailbox/mail_ext/address_equality.rb +9 -0
  35. data/lib/action_mailbox/mail_ext/address_wrapping.rb +9 -0
  36. data/lib/action_mailbox/mail_ext/addresses.rb +29 -0
  37. data/lib/action_mailbox/mail_ext/from_source.rb +7 -0
  38. data/lib/action_mailbox/mail_ext/recipients.rb +9 -0
  39. data/lib/action_mailbox/relayer.rb +75 -0
  40. data/lib/action_mailbox/router.rb +42 -0
  41. data/lib/action_mailbox/router/route.rb +42 -0
  42. data/lib/action_mailbox/routing.rb +22 -0
  43. data/lib/action_mailbox/test_case.rb +12 -0
  44. data/lib/action_mailbox/test_helper.rb +44 -0
  45. data/lib/action_mailbox/version.rb +10 -0
  46. data/lib/rails/generators/installer.rb +10 -0
  47. data/lib/rails/generators/mailbox/USAGE +12 -0
  48. data/lib/rails/generators/mailbox/mailbox_generator.rb +32 -0
  49. data/lib/rails/generators/mailbox/templates/application_mailbox.rb.tt +3 -0
  50. data/lib/rails/generators/mailbox/templates/mailbox.rb.tt +4 -0
  51. data/lib/rails/generators/test_unit/mailbox_generator.rb +20 -0
  52. data/lib/rails/generators/test_unit/templates/mailbox_test.rb.tt +13 -0
  53. data/lib/tasks/ingress.rake +72 -0
  54. data/lib/tasks/install.rake +20 -0
  55. metadata +184 -0
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_mailbox/mail_ext"
4
+
5
+ module ActionMailbox
6
+ extend ActiveSupport::Autoload
7
+
8
+ autoload :Base
9
+ autoload :Router
10
+ autoload :TestCase
11
+
12
+ mattr_accessor :ingress
13
+ mattr_accessor :logger
14
+ mattr_accessor :incinerate_after, default: 30.days
15
+ mattr_accessor :queues, default: {}
16
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/rescuable"
4
+
5
+ require "action_mailbox/callbacks"
6
+ require "action_mailbox/routing"
7
+
8
+ module ActionMailbox
9
+ # The base class for all application mailboxes. Not intended to be inherited from directly. Inherit from
10
+ # +ApplicationMailbox+ instead, as that's where the app-specific routing is configured. This routing
11
+ # is specified in the following ways:
12
+ #
13
+ # class ApplicationMailbox < ActionMailbox::Base
14
+ # # Any of the recipients of the mail (whether to, cc, bcc) are matched against the regexp.
15
+ # routing /^replies@/i => :replies
16
+ #
17
+ # # Any of the recipients of the mail (whether to, cc, bcc) needs to be an exact match for the string.
18
+ # routing "help@example.com" => :help
19
+ #
20
+ # # Any callable (proc, lambda, etc) object is passed the inbound_email record and is a match if true.
21
+ # routing ->(inbound_email) { inbound_email.mail.to.size > 2 } => :multiple_recipients
22
+ #
23
+ # # Any object responding to #match? is called with the inbound_email record as an argument. Match if true.
24
+ # routing CustomAddress.new => :custom
25
+ #
26
+ # # Any inbound_email that has not been already matched will be sent to the BackstopMailbox.
27
+ # routing :all => :backstop
28
+ # end
29
+ #
30
+ # Application mailboxes need to overwrite the +#process+ method, which is invoked by the framework after
31
+ # callbacks have been run. The callbacks available are: +before_processing+, +after_processing+, and
32
+ # +around_processing+. The primary use case is ensure certain preconditions to processing are fulfilled
33
+ # using +before_processing+ callbacks.
34
+ #
35
+ # If a precondition fails to be met, you can halt the processing using the +#bounced!+ method,
36
+ # which will silently prevent any further processing, but not actually send out any bounce notice. You
37
+ # can also pair this behavior with the invocation of an Action Mailer class responsible for sending out
38
+ # an actual bounce email. This is done using the +#bounce_with+ method, which takes the mail object returned
39
+ # by an Action Mailer method, like so:
40
+ #
41
+ # class ForwardsMailbox < ApplicationMailbox
42
+ # before_processing :ensure_sender_is_a_user
43
+ #
44
+ # private
45
+ # def ensure_sender_is_a_user
46
+ # unless User.exist?(email_address: mail.from)
47
+ # bounce_with UserRequiredMailer.missing(inbound_email)
48
+ # end
49
+ # end
50
+ # end
51
+ #
52
+ # During the processing of the inbound email, the status will be tracked. Before processing begins,
53
+ # the email will normally have the +pending+ status. Once processing begins, just before callbacks
54
+ # and the +#process+ method is called, the status is changed to +processing+. If processing is allowed to
55
+ # complete, the status is changed to +delivered+. If a bounce is triggered, then +bounced+. If an unhandled
56
+ # exception is bubbled up, then +failed+.
57
+ #
58
+ # Exceptions can be handled at the class level using the familiar +Rescuable+ approach:
59
+ #
60
+ # class ForwardsMailbox < ApplicationMailbox
61
+ # rescue_from(ApplicationSpecificVerificationError) { bounced! }
62
+ # end
63
+ class Base
64
+ include ActiveSupport::Rescuable
65
+ include ActionMailbox::Callbacks, ActionMailbox::Routing
66
+
67
+ attr_reader :inbound_email
68
+ delegate :mail, :delivered!, :bounced!, to: :inbound_email
69
+
70
+ delegate :logger, to: ActionMailbox
71
+
72
+ def self.receive(inbound_email)
73
+ new(inbound_email).perform_processing
74
+ end
75
+
76
+ def initialize(inbound_email)
77
+ @inbound_email = inbound_email
78
+ end
79
+
80
+ def perform_processing #:nodoc:
81
+ track_status_of_inbound_email do
82
+ run_callbacks :process do
83
+ process
84
+ end
85
+ end
86
+ rescue => exception
87
+ # TODO: Include a reference to the inbound_email in the exception raised so error handling becomes easier
88
+ rescue_with_handler(exception) || raise
89
+ end
90
+
91
+ def process
92
+ # Overwrite in subclasses
93
+ end
94
+
95
+ def finished_processing? #:nodoc:
96
+ inbound_email.delivered? || inbound_email.bounced?
97
+ end
98
+
99
+
100
+ # Enqueues the given +message+ for delivery and changes the inbound email's status to +:bounced+.
101
+ def bounce_with(message)
102
+ inbound_email.bounced!
103
+ message.deliver_later
104
+ end
105
+
106
+ private
107
+ def track_status_of_inbound_email
108
+ inbound_email.processing!
109
+ yield
110
+ inbound_email.delivered! unless inbound_email.bounced?
111
+ rescue
112
+ inbound_email.failed!
113
+ raise
114
+ end
115
+ end
116
+ end
117
+
118
+ ActiveSupport.run_load_hooks :action_mailbox, ActionMailbox::Base
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/callbacks"
4
+
5
+ module ActionMailbox
6
+ # Defines the callbacks related to processing.
7
+ module Callbacks
8
+ extend ActiveSupport::Concern
9
+ include ActiveSupport::Callbacks
10
+
11
+ TERMINATOR = ->(mailbox, chain) do
12
+ chain.call
13
+ mailbox.finished_processing?
14
+ end
15
+
16
+ included do
17
+ define_callbacks :process, terminator: TERMINATOR, skip_after_callbacks_if_terminated: true
18
+ end
19
+
20
+ class_methods do
21
+ def before_processing(*methods, &block)
22
+ set_callback(:process, :before, *methods, &block)
23
+ end
24
+
25
+ def after_processing(*methods, &block)
26
+ set_callback(:process, :after, *methods, &block)
27
+ end
28
+
29
+ def around_processing(*methods, &block)
30
+ set_callback(:process, :around, *methods, &block)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "action_controller/railtie"
5
+ require "active_job/railtie"
6
+ require "active_record/railtie"
7
+ require "active_storage/engine"
8
+
9
+ require "action_mailbox"
10
+
11
+ module ActionMailbox
12
+ class Engine < Rails::Engine
13
+ isolate_namespace ActionMailbox
14
+ config.eager_load_namespaces << ActionMailbox
15
+
16
+ config.action_mailbox = ActiveSupport::OrderedOptions.new
17
+ config.action_mailbox.incinerate_after = 30.days
18
+
19
+ config.action_mailbox.queues = ActiveSupport::InheritableOptions.new \
20
+ incineration: :action_mailbox_incineration, routing: :action_mailbox_routing
21
+
22
+ initializer "action_mailbox.config" do
23
+ config.after_initialize do |app|
24
+ ActionMailbox.logger = app.config.action_mailbox.logger || Rails.logger
25
+ ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days
26
+ ActionMailbox.queues = app.config.action_mailbox.queues || {}
27
+ end
28
+ end
29
+
30
+ initializer "action_mailbox.ingress" do
31
+ config.after_initialize do |app|
32
+ if ActionMailbox.ingress = app.config.action_mailbox.ingress.presence
33
+ config.to_prepare do
34
+ if ingress_controller_class = "ActionMailbox::Ingresses::#{ActionMailbox.ingress.to_s.classify}::InboundEmailsController".safe_constantize
35
+ ingress_controller_class.prepare
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMailbox
4
+ # Returns the currently-loaded version of Action Mailbox as a <tt>Gem::Version</tt>.
5
+ def self.gem_version
6
+ Gem::Version.new VERSION::STRING
7
+ end
8
+
9
+ module VERSION
10
+ MAJOR = 6
11
+ MINOR = 0
12
+ TINY = 0
13
+ PRE = "beta1"
14
+
15
+ STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16
+ end
17
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+
5
+ # The hope is to upstream most of these basic additions to the Mail gem's Mail object. But until then, here they lay!
6
+ Dir["#{File.expand_path(File.dirname(__FILE__))}/mail_ext/*"].each { |path| require "action_mailbox/mail_ext/#{File.basename(path)}" }
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mail
4
+ class Address
5
+ def ==(other_address)
6
+ other_address.is_a?(Mail::Address) && to_s == other_address.to_s
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mail
4
+ class Address
5
+ def self.wrap(address)
6
+ address.is_a?(Mail::Address) ? address : Mail::Address.new(address)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mail
4
+ class Message
5
+ def from_address
6
+ header[:from]&.address_list&.addresses&.first
7
+ end
8
+
9
+ def recipients_addresses
10
+ to_addresses + cc_addresses + bcc_addresses + x_original_to_addresses
11
+ end
12
+
13
+ def to_addresses
14
+ Array(header[:to]&.address_list&.addresses)
15
+ end
16
+
17
+ def cc_addresses
18
+ Array(header[:cc]&.address_list&.addresses)
19
+ end
20
+
21
+ def bcc_addresses
22
+ Array(header[:bcc]&.address_list&.addresses)
23
+ end
24
+
25
+ def x_original_to_addresses
26
+ Array(header[:x_original_to]).collect { |header| Mail::Address.new header.to_s }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mail
4
+ def self.from_source(source)
5
+ Mail.new Mail::Utilities.binary_unsafe_to_crlf(source.to_s)
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mail
4
+ class Message
5
+ def recipients
6
+ Array(to) + Array(cc) + Array(bcc) + Array(header[:x_original_to]).map(&:to_s)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_mailbox/version"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module ActionMailbox
8
+ class Relayer
9
+ class Result < Struct.new(:status_code, :message)
10
+ def success?
11
+ !failure?
12
+ end
13
+
14
+ def failure?
15
+ transient_failure? || permanent_failure?
16
+ end
17
+
18
+ def transient_failure?
19
+ status_code.start_with?("4.")
20
+ end
21
+
22
+ def permanent_failure?
23
+ status_code.start_with?("5.")
24
+ end
25
+ end
26
+
27
+ CONTENT_TYPE = "message/rfc822"
28
+ USER_AGENT = "Action Mailbox relayer v#{ActionMailbox.version}"
29
+
30
+ attr_reader :uri, :username, :password
31
+
32
+ def initialize(url:, username: "actionmailbox", password:)
33
+ @uri, @username, @password = URI(url), username, password
34
+ end
35
+
36
+ def relay(source)
37
+ case response = post(source)
38
+ when Net::HTTPSuccess
39
+ Result.new "2.0.0", "Successfully relayed message to ingress"
40
+ when Net::HTTPUnauthorized
41
+ Result.new "4.7.0", "Invalid credentials for ingress"
42
+ else
43
+ Result.new "4.0.0", "HTTP #{response.code}"
44
+ end
45
+ rescue IOError, SocketError, SystemCallError => error
46
+ Result.new "4.4.2", "Network error relaying to ingress: #{error.message}"
47
+ rescue Timeout::Error
48
+ Result.new "4.4.2", "Timed out relaying to ingress"
49
+ rescue => error
50
+ Result.new "4.0.0", "Error relaying to ingress: #{error.message}"
51
+ end
52
+
53
+ private
54
+ def post(source)
55
+ client.post uri, source,
56
+ "Content-Type" => CONTENT_TYPE,
57
+ "User-Agent" => USER_AGENT,
58
+ "Authorization" => "Basic #{Base64.strict_encode64(username + ":" + password)}"
59
+ end
60
+
61
+ def client
62
+ @client ||= Net::HTTP.new(uri.host, uri.port).tap do |connection|
63
+ if uri.scheme == "https"
64
+ require "openssl"
65
+
66
+ connection.use_ssl = true
67
+ connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
68
+ end
69
+
70
+ connection.open_timeout = 1
71
+ connection.read_timeout = 10
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMailbox
4
+ # Encapsulates the routes that live on the ApplicationMailbox and performs the actual routing when
5
+ # an inbound_email is received.
6
+ class Router
7
+ class RoutingError < StandardError; end
8
+
9
+ def initialize
10
+ @routes = []
11
+ end
12
+
13
+ def add_routes(routes)
14
+ routes.each do |(address, mailbox_name)|
15
+ add_route address, to: mailbox_name
16
+ end
17
+ end
18
+
19
+ def add_route(address, to:)
20
+ routes.append Route.new(address, to: to)
21
+ end
22
+
23
+ def route(inbound_email)
24
+ if mailbox = match_to_mailbox(inbound_email)
25
+ mailbox.receive(inbound_email)
26
+ else
27
+ inbound_email.bounced!
28
+
29
+ raise RoutingError
30
+ end
31
+ end
32
+
33
+ private
34
+ attr_reader :routes
35
+
36
+ def match_to_mailbox(inbound_email)
37
+ routes.detect { |route| route.match?(inbound_email) }.try(:mailbox_class)
38
+ end
39
+ end
40
+ end
41
+
42
+ require "action_mailbox/router/route"
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMailbox
4
+ # Encapsulates a route, which can then be matched against an inbound_email and provide a lookup of the matching
5
+ # mailbox class. See examples for the different route addresses and how to use them in the +ActionMailbox::Base+
6
+ # documentation.
7
+ class Router::Route
8
+ attr_reader :address, :mailbox_name
9
+
10
+ def initialize(address, to:)
11
+ @address, @mailbox_name = address, to
12
+
13
+ ensure_valid_address
14
+ end
15
+
16
+ def match?(inbound_email)
17
+ case address
18
+ when :all
19
+ true
20
+ when String
21
+ inbound_email.mail.recipients.any? { |recipient| address.casecmp?(recipient) }
22
+ when Regexp
23
+ inbound_email.mail.recipients.any? { |recipient| address.match?(recipient) }
24
+ when Proc
25
+ address.call(inbound_email)
26
+ else
27
+ address.match?(inbound_email)
28
+ end
29
+ end
30
+
31
+ def mailbox_class
32
+ "#{mailbox_name.to_s.camelize}Mailbox".constantize
33
+ end
34
+
35
+ private
36
+ def ensure_valid_address
37
+ unless [ Symbol, String, Regexp, Proc ].any? { |klass| address.is_a?(klass) } || address.respond_to?(:match?)
38
+ raise ArgumentError, "Expected a Symbol, String, Regexp, Proc, or matchable, got #{address.inspect}"
39
+ end
40
+ end
41
+ end
42
+ end