aboutsummaryrefslogtreecommitdiffstats
path: root/actionmailbox/lib/action_mailbox
diff options
context:
space:
mode:
Diffstat (limited to 'actionmailbox/lib/action_mailbox')
-rw-r--r--actionmailbox/lib/action_mailbox/base.rb113
-rw-r--r--actionmailbox/lib/action_mailbox/callbacks.rb34
-rw-r--r--actionmailbox/lib/action_mailbox/engine.rb37
-rw-r--r--actionmailbox/lib/action_mailbox/gem_version.rb17
-rw-r--r--actionmailbox/lib/action_mailbox/mail_ext.rb6
-rw-r--r--actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb7
-rw-r--r--actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb7
-rw-r--r--actionmailbox/lib/action_mailbox/mail_ext/addresses.rb27
-rw-r--r--actionmailbox/lib/action_mailbox/mail_ext/from_source.rb7
-rw-r--r--actionmailbox/lib/action_mailbox/mail_ext/recipients.rb7
-rw-r--r--actionmailbox/lib/action_mailbox/postfix_relayer.rb67
-rw-r--r--actionmailbox/lib/action_mailbox/router.rb40
-rw-r--r--actionmailbox/lib/action_mailbox/router/route.rb40
-rw-r--r--actionmailbox/lib/action_mailbox/routing.rb22
-rw-r--r--actionmailbox/lib/action_mailbox/test_case.rb10
-rw-r--r--actionmailbox/lib/action_mailbox/test_helper.rb44
-rw-r--r--actionmailbox/lib/action_mailbox/version.rb10
17 files changed, 495 insertions, 0 deletions
diff --git a/actionmailbox/lib/action_mailbox/base.rb b/actionmailbox/lib/action_mailbox/base.rb
new file mode 100644
index 0000000000..bd76c8ff5f
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/base.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require "active_support/rescuable"
+
+require "action_mailbox/callbacks"
+require "action_mailbox/routing"
+
+# The base class for all application mailboxes. Not intended to be inherited from directly. Inherit from
+# `ApplicationMailbox` instead, as that's where the app-specific routing is configured. This routing
+# is specified in the following ways:
+#
+# class ApplicationMailbox < ActionMailbox::Base
+# # Any of the recipients of the mail (whether to, cc, bcc) are matched against the regexp.
+# routing /^replies@/i => :replies
+#
+# # Any of the recipients of the mail (whether to, cc, bcc) needs to be an exact match for the string.
+# routing "help@example.com" => :help
+#
+# # Any callable (proc, lambda, etc) object is passed the inbound_email record and is a match if true.
+# routing ->(inbound_email) { inbound_email.mail.to.size > 2 } => :multiple_recipients
+#
+# # Any object responding to #match? is called with the inbound_email record as an argument. Match if true.
+# routing CustomAddress.new => :custom
+#
+# # Any inbound_email that has not been already matched will be sent to the BackstopMailbox.
+# routing :all => :backstop
+# end
+#
+# Application mailboxes need to overwrite the `#process` method, which is invoked by the framework after
+# callbacks have been run. The callbacks available are: `before_processing`, `after_processing`, and
+# `around_processing`. The primary use case is ensure certain preconditions to processing are fulfilled
+# using `before_processing` callbacks.
+#
+# If a precondition fails to be met, you can halt the processing using the `#bounced!` method,
+# which will silently prevent any further processing, but not actually send out any bounce notice. You
+# can also pair this behavior with the invocation of an Action Mailer class responsible for sending out
+# an actual bounce email. This is done using the `#bounce_with` method, which takes the mail object returned
+# by an Action Mailer method, like so:
+#
+# class ForwardsMailbox < ApplicationMailbox
+# before_processing :ensure_sender_is_a_user
+#
+# private
+# def ensure_sender_is_a_user
+# unless User.exist?(email_address: mail.from)
+# bounce_with UserRequiredMailer.missing(inbound_email)
+# end
+# end
+# end
+#
+# During the processing of the inbound email, the status will be tracked. Before processing begins,
+# the email will normally have the `pending` status. Once processing begins, just before callbacks
+# and the `#process` method is called, the status is changed to `processing`. If processing is allowed to
+# complete, the status is changed to `delivered`. If a bounce is triggered, then `bounced`. If an unhandled
+# exception is bubbled up, then `failed`.
+#
+# Exceptions can be handled at the class level using the familiar `Rescuable` approach:
+#
+# class ForwardsMailbox < ApplicationMailbox
+# rescue_from(ApplicationSpecificVerificationError) { bounced! }
+# end
+class ActionMailbox::Base
+ include ActiveSupport::Rescuable
+ include ActionMailbox::Callbacks, ActionMailbox::Routing
+
+ attr_reader :inbound_email
+ delegate :mail, :delivered!, :bounced!, to: :inbound_email
+
+ delegate :logger, to: ActionMailbox
+
+ def self.receive(inbound_email)
+ new(inbound_email).perform_processing
+ end
+
+ def initialize(inbound_email)
+ @inbound_email = inbound_email
+ end
+
+ def perform_processing
+ track_status_of_inbound_email do
+ run_callbacks :process do
+ process
+ end
+ end
+ rescue => exception
+ # TODO: Include a reference to the inbound_email in the exception raised so error handling becomes easier
+ rescue_with_handler(exception) || raise
+ end
+
+ def process
+ # Overwrite in subclasses
+ end
+
+ def finished_processing?
+ inbound_email.delivered? || inbound_email.bounced?
+ end
+
+
+ def bounce_with(message)
+ inbound_email.bounced!
+ message.deliver_later
+ end
+
+ private
+ def track_status_of_inbound_email
+ inbound_email.processing!
+ yield
+ inbound_email.delivered! unless inbound_email.bounced?
+ rescue
+ inbound_email.failed!
+ raise
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/callbacks.rb b/actionmailbox/lib/action_mailbox/callbacks.rb
new file mode 100644
index 0000000000..2b7212284b
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/callbacks.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "active_support/callbacks"
+
+module ActionMailbox
+ # Defines the callbacks related to processing.
+ module Callbacks
+ extend ActiveSupport::Concern
+ include ActiveSupport::Callbacks
+
+ TERMINATOR = ->(mailbox, chain) do
+ chain.call
+ mailbox.finished_processing?
+ end
+
+ included do
+ define_callbacks :process, terminator: TERMINATOR, skip_after_callbacks_if_terminated: true
+ end
+
+ class_methods do
+ def before_processing(*methods, &block)
+ set_callback(:process, :before, *methods, &block)
+ end
+
+ def after_processing(*methods, &block)
+ set_callback(:process, :after, *methods, &block)
+ end
+
+ def around_processing(*methods, &block)
+ set_callback(:process, :around, *methods, &block)
+ end
+ end
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/engine.rb b/actionmailbox/lib/action_mailbox/engine.rb
new file mode 100644
index 0000000000..0400469ff7
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/engine.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "rails/engine"
+require "action_mailbox"
+
+module ActionMailbox
+ class Engine < Rails::Engine
+ isolate_namespace ActionMailbox
+ config.eager_load_namespaces << ActionMailbox
+
+ config.action_mailbox = ActiveSupport::OrderedOptions.new
+ config.action_mailbox.incinerate_after = 30.days
+
+ config.action_mailbox.queues = ActiveSupport::InheritableOptions.new \
+ incineration: :action_mailbox_incineration, routing: :action_mailbox_routing
+
+ initializer "action_mailbox.config" do
+ config.after_initialize do |app|
+ ActionMailbox.logger = app.config.action_mailbox.logger || Rails.logger
+ ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days
+ ActionMailbox.queues = app.config.action_mailbox.queues || {}
+ end
+ end
+
+ initializer "action_mailbox.ingress" do
+ config.after_initialize do |app|
+ if ActionMailbox.ingress = app.config.action_mailbox.ingress.presence
+ config.to_prepare do
+ if ingress_controller_class = "ActionMailbox::Ingresses::#{ActionMailbox.ingress.to_s.classify}::InboundEmailsController".safe_constantize
+ ingress_controller_class.prepare
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/gem_version.rb b/actionmailbox/lib/action_mailbox/gem_version.rb
new file mode 100644
index 0000000000..3959de9ce1
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/gem_version.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ActionMailbox
+ # Returns the currently-loaded version of Action Mailbox as a <tt>Gem::Version</tt>.
+ def self.gem_version
+ Gem::Version.new VERSION::STRING
+ end
+
+ module VERSION
+ MAJOR = 6
+ MINOR = 0
+ TINY = 0
+ PRE = "alpha"
+
+ STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/mail_ext.rb b/actionmailbox/lib/action_mailbox/mail_ext.rb
new file mode 100644
index 0000000000..c4d277a1f9
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/mail_ext.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+require "mail"
+
+# The hope is to upstream most of these basic additions to the Mail gem's Mail object. But until then, here they lay!
+Dir["#{File.expand_path(File.dirname(__FILE__))}/mail_ext/*"].each { |path| require "action_mailbox/mail_ext/#{File.basename(path)}" }
diff --git a/actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb b/actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb
new file mode 100644
index 0000000000..69243a666e
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Mail::Address
+ def ==(other_address)
+ other_address.is_a?(Mail::Address) && to_s == other_address.to_s
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb b/actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb
new file mode 100644
index 0000000000..fcdfbb6f6f
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Mail::Address
+ def self.wrap(address)
+ address.is_a?(Mail::Address) ? address : Mail::Address.new(address)
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb b/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb
new file mode 100644
index 0000000000..377373bee6
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class Mail::Message
+ def from_address
+ header[:from]&.address_list&.addresses&.first
+ end
+
+ def recipients_addresses
+ to_addresses + cc_addresses + bcc_addresses + x_original_to_addresses
+ end
+
+ def to_addresses
+ Array(header[:to]&.address_list&.addresses)
+ end
+
+ def cc_addresses
+ Array(header[:cc]&.address_list&.addresses)
+ end
+
+ def bcc_addresses
+ Array(header[:bcc]&.address_list&.addresses)
+ end
+
+ def x_original_to_addresses
+ Array(header[:x_original_to]).collect { |header| Mail::Address.new header.to_s }
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/mail_ext/from_source.rb b/actionmailbox/lib/action_mailbox/mail_ext/from_source.rb
new file mode 100644
index 0000000000..17b7fc80ad
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/mail_ext/from_source.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Mail
+ def self.from_source(source)
+ Mail.new Mail::Utilities.binary_unsafe_to_crlf(source.to_s)
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/mail_ext/recipients.rb b/actionmailbox/lib/action_mailbox/mail_ext/recipients.rb
new file mode 100644
index 0000000000..a8ac42d602
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/mail_ext/recipients.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Mail::Message
+ def recipients
+ Array(to) + Array(cc) + Array(bcc) + Array(header[:x_original_to]).map(&:to_s)
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/postfix_relayer.rb b/actionmailbox/lib/action_mailbox/postfix_relayer.rb
new file mode 100644
index 0000000000..d43c56ed2b
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/postfix_relayer.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require "action_mailbox/version"
+require "net/http"
+require "uri"
+
+module ActionMailbox
+ class PostfixRelayer
+ class Result < Struct.new(:output)
+ def success?
+ !failure?
+ end
+
+ def failure?
+ output.match?(/\A[45]\.\d{1,3}\.\d{1,3}(\s|\z)/)
+ end
+ end
+
+ CONTENT_TYPE = "message/rfc822"
+ USER_AGENT = "Action Mailbox Postfix relayer v#{ActionMailbox.version}"
+
+ attr_reader :uri, :username, :password
+
+ def initialize(url:, username: "actionmailbox", password:)
+ @uri, @username, @password = URI(url), username, password
+ end
+
+ def relay(source)
+ case response = post(source)
+ when Net::HTTPSuccess
+ Result.new "2.0.0 Successfully relayed message to Postfix ingress"
+ when Net::HTTPUnauthorized
+ Result.new "4.7.0 Invalid credentials for Postfix ingress"
+ else
+ Result.new "4.0.0 HTTP #{response.code}"
+ end
+ rescue IOError, SocketError, SystemCallError => error
+ Result.new "4.4.2 Network error relaying to Postfix ingress: #{error.message}"
+ rescue Timeout::Error
+ Result.new "4.4.2 Timed out relaying to Postfix ingress"
+ rescue => error
+ Result.new "4.0.0 Error relaying to Postfix ingress: #{error.message}"
+ end
+
+ private
+ def post(source)
+ client.post uri, source,
+ "Content-Type" => CONTENT_TYPE,
+ "User-Agent" => USER_AGENT,
+ "Authorization" => "Basic #{Base64.strict_encode64(username + ":" + password)}"
+ end
+
+ def client
+ @client ||= Net::HTTP.new(uri.host, uri.port).tap do |connection|
+ if uri.scheme == "https"
+ require "openssl"
+
+ connection.use_ssl = true
+ connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
+ end
+
+ connection.open_timeout = 1
+ connection.read_timeout = 10
+ end
+ end
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/router.rb b/actionmailbox/lib/action_mailbox/router.rb
new file mode 100644
index 0000000000..0f041a8389
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/router.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+# Encapsulates the routes that live on the ApplicationMailbox and performs the actual routing when
+# an inbound_email is received.
+class ActionMailbox::Router
+ class RoutingError < StandardError; end
+
+ def initialize
+ @routes = []
+ end
+
+ def add_routes(routes)
+ routes.each do |(address, mailbox_name)|
+ add_route address, to: mailbox_name
+ end
+ end
+
+ def add_route(address, to:)
+ routes.append Route.new(address, to: to)
+ end
+
+ def route(inbound_email)
+ if mailbox = match_to_mailbox(inbound_email)
+ mailbox.receive(inbound_email)
+ else
+ inbound_email.bounced!
+
+ raise RoutingError
+ end
+ end
+
+ private
+ attr_reader :routes
+
+ def match_to_mailbox(inbound_email)
+ routes.detect { |route| route.match?(inbound_email) }.try(:mailbox_class)
+ end
+end
+
+require "action_mailbox/router/route"
diff --git a/actionmailbox/lib/action_mailbox/router/route.rb b/actionmailbox/lib/action_mailbox/router/route.rb
new file mode 100644
index 0000000000..adb9f94c1a
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/router/route.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+# Encapsulates a route, which can then be matched against an inbound_email and provide a lookup of the matching
+# mailbox class. See examples for the different route addresses and how to use them in the `ActionMailbox::Base`
+# documentation.
+class ActionMailbox::Router::Route
+ attr_reader :address, :mailbox_name
+
+ def initialize(address, to:)
+ @address, @mailbox_name = address, to
+
+ ensure_valid_address
+ end
+
+ def match?(inbound_email)
+ case address
+ when :all
+ true
+ when String
+ inbound_email.mail.recipients.any? { |recipient| address.casecmp?(recipient) }
+ when Regexp
+ inbound_email.mail.recipients.any? { |recipient| address.match?(recipient) }
+ when Proc
+ address.call(inbound_email)
+ else
+ address.match?(inbound_email)
+ end
+ end
+
+ def mailbox_class
+ "#{mailbox_name.to_s.camelize}Mailbox".constantize
+ end
+
+ private
+ def ensure_valid_address
+ unless [ Symbol, String, Regexp, Proc ].any? { |klass| address.is_a?(klass) } || address.respond_to?(:match?)
+ raise ArgumentError, "Expected a Symbol, String, Regexp, Proc, or matchable, got #{address.inspect}"
+ end
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/routing.rb b/actionmailbox/lib/action_mailbox/routing.rb
new file mode 100644
index 0000000000..1ea96c8a9d
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/routing.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module ActionMailbox
+ # See `ActionMailbox::Base` for how to specify routing.
+ module Routing
+ extend ActiveSupport::Concern
+
+ included do
+ cattr_accessor :router, default: ActionMailbox::Router.new
+ end
+
+ class_methods do
+ def routing(routes)
+ router.add_routes(routes)
+ end
+
+ def route(inbound_email)
+ router.route(inbound_email)
+ end
+ end
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/test_case.rb b/actionmailbox/lib/action_mailbox/test_case.rb
new file mode 100644
index 0000000000..a501e8a7ca
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/test_case.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require "action_mailbox/test_helper"
+require "active_support/test_case"
+
+module ActionMailbox
+ class TestCase < ActiveSupport::TestCase
+ include ActionMailbox::TestHelper
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/test_helper.rb b/actionmailbox/lib/action_mailbox/test_helper.rb
new file mode 100644
index 0000000000..02c52fb779
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/test_helper.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "mail"
+
+module ActionMailbox
+ module TestHelper
+ # Create an `InboundEmail` record using an eml fixture in the format of message/rfc822
+ # referenced with +fixture_name+ located in +test/fixtures/files/fixture_name+.
+ def create_inbound_email_from_fixture(fixture_name, status: :processing)
+ create_inbound_email_from_source file_fixture(fixture_name).read, status: status
+ end
+
+ # Create an `InboundEmail` by specifying it using `Mail.new` options. Example:
+ #
+ # create_inbound_email_from_mail(from: "david@loudthinking.com", subject: "Hello!")
+ def create_inbound_email_from_mail(status: :processing, **mail_options)
+ create_inbound_email_from_source Mail.new(mail_options).to_s, status: status
+ end
+
+ # Create an `InboundEmail` using the raw rfc822 `source` as text.
+ def create_inbound_email_from_source(source, status: :processing)
+ ActionMailbox::InboundEmail.create_and_extract_message_id! source, status: status
+ end
+
+
+ # Create an `InboundEmail` from fixture using the same arguments as `create_inbound_email_from_fixture`
+ # and immediately route it to processing.
+ def receive_inbound_email_from_fixture(*args)
+ create_inbound_email_from_fixture(*args).tap(&:route)
+ end
+
+ # Create an `InboundEmail` from fixture using the same arguments as `create_inbound_email_from_mail`
+ # and immediately route it to processing.
+ def receive_inbound_email_from_mail(**kwargs)
+ create_inbound_email_from_mail(**kwargs).tap(&:route)
+ end
+
+ # Create an `InboundEmail` from fixture using the same arguments as `create_inbound_email_from_source`
+ # and immediately route it to processing.
+ def receive_inbound_email_from_source(**kwargs)
+ create_inbound_email_from_source(**kwargs).tap(&:route)
+ end
+ end
+end
diff --git a/actionmailbox/lib/action_mailbox/version.rb b/actionmailbox/lib/action_mailbox/version.rb
new file mode 100644
index 0000000000..e65d27f5dd
--- /dev/null
+++ b/actionmailbox/lib/action_mailbox/version.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require_relative "gem_version"
+
+module ActionMailbox
+ # Returns the currently-loaded version of Action Mailbox as a <tt>Gem::Version</tt>.
+ def self.version
+ gem_version
+ end
+end