All Files ( 91.94% covered at 4.04 hits/line )
37 files in total.
509 relevant lines,
468 lines covered and
41 lines missed.
(
91.94%
)
# frozen_string_literal: true
- 1
module ActionMailbox
# The base class for all Action Mailbox ingress controllers.
- 1
class BaseController < ActionController::Base
- 1
skip_forgery_protection if default_protect_from_forgery
- 1
before_action :ensure_configured
- 1
private
- 1
def ensure_configured
- 26
unless ActionMailbox.ingress == ingress_name
head :not_found
end
end
- 1
def ingress_name
- 26
self.class.name.remove(/\AActionMailbox::Ingresses::/, /::InboundEmailsController\z/).underscore.to_sym
end
- 1
def authenticate_by_password
- 15
if password.present?
- 9
http_basic_authenticate_or_request_with name: "actionmailbox", password: password, realm: "Action Mailbox"
else
- 6
raise ArgumentError, "Missing required ingress credentials"
end
end
- 1
def password
- 24
Rails.application.credentials.dig(:action_mailbox, :ingress_password) || ENV["RAILS_INBOUND_EMAIL_PASSWORD"]
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# Ingests inbound emails from Mailgun. Requires the following parameters:
#
# - +body-mime+: The full RFC 822 message
# - +timestamp+: The current time according to Mailgun as the number of seconds passed since the UNIX epoch
# - +token+: A randomly-generated, 50-character string
# - +signature+: A hexadecimal HMAC-SHA256 of the timestamp concatenated with the token, generated using the Mailgun Signing key
#
# Authenticates requests by validating their signatures.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request's signature could not be validated, or if its timestamp is more than 2 minutes old
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Mailgun
# - <tt>422 Unprocessable Entity</tt> if the request is missing required parameters
# - <tt>500 Server Error</tt> if the Mailgun Signing key is missing, or one of the Active Record database,
# the Active Storage service, or the Active Job backend is misconfigured or unavailable
#
# == Usage
#
# 1. Give Action Mailbox your Mailgun Signing key (which you can find under Settings -> Security & Users -> API security in Mailgun)
# so it can authenticate requests to the Mailgun ingress.
#
# Use <tt>bin/rails credentials:edit</tt> to add your Signing key to your application's encrypted credentials under
# +action_mailbox.mailgun_signing_key+, where Action Mailbox will automatically find it:
#
# action_mailbox:
# mailgun_signing_key: ...
#
# Alternatively, provide your Signing key in the +MAILGUN_INGRESS_SIGNING_KEY+ environment variable.
#
# 2. Tell Action Mailbox to accept emails from Mailgun:
#
# # config/environments/production.rb
# config.action_mailbox.ingress = :mailgun
#
# 3. {Configure Mailgun}[https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages]
# to forward inbound emails to +/rails/action_mailbox/mailgun/inbound_emails/mime+.
#
# If your application lived at <tt>https://example.com</tt>, you would specify the fully-qualified URL
# <tt>https://example.com/rails/action_mailbox/mailgun/inbound_emails/mime</tt>.
- 1
class Ingresses::Mailgun::InboundEmailsController < ActionMailbox::BaseController
- 1
before_action :authenticate
- 1
def create
- 2
ActionMailbox::InboundEmail.create_and_extract_message_id! mail
end
- 1
private
- 1
def mail
- 2
params.require("body-mime").tap do |raw_email|
- 2
raw_email.prepend("X-Original-To: ", params.require(:recipient), "\n") if params.key?(:recipient)
end
end
- 1
def authenticate
- 6
head :unauthorized unless authenticated?
end
- 1
def authenticated?
- 6
if key.present?
Authenticator.new(
key: key,
timestamp: params.require(:timestamp),
token: params.require(:token),
signature: params.require(:signature)
- 4
).authenticated?
else
- 2
raise ArgumentError, <<~MESSAGE.squish
Missing required Mailgun Signing key. Set action_mailbox.mailgun_signing_key in your application's
encrypted credentials or provide the MAILGUN_INGRESS_SIGNING_KEY environment variable.
MESSAGE
end
end
- 1
def key
- 10
if Rails.application.credentials.dig(:action_mailbox, :mailgun_api_key)
ActiveSupport::Deprecation.warn(<<-MSG.squish)
Rails.application.credentials.action_mailbox.api_key is deprecated and will be ignored in Rails 6.2.
Use Rails.application.credentials.action_mailbox.signing_key instead.
MSG
Rails.application.credentials.dig(:action_mailbox, :mailgun_api_key)
- 10
elsif ENV["MAILGUN_INGRESS_API_KEY"]
ActiveSupport::Deprecation.warn(<<-MSG.squish)
The MAILGUN_INGRESS_API_KEY environment variable is deprecated and will be ignored in Rails 6.2.
Use MAILGUN_INGRESS_SIGNING_KEY instead.
MSG
ENV["MAILGUN_INGRESS_API_KEY"]
else
- 10
Rails.application.credentials.dig(:action_mailbox, :mailgun_signing_key) || ENV["MAILGUN_INGRESS_SIGNING_KEY"]
end
end
- 1
class Authenticator
- 1
attr_reader :key, :timestamp, :token, :signature
- 1
def initialize(key:, timestamp:, token:, signature:)
- 4
@key, @timestamp, @token, @signature = key, Integer(timestamp), token, signature
end
- 1
def authenticated?
- 4
signed? && recent?
end
- 1
private
- 1
def signed?
- 4
ActiveSupport::SecurityUtils.secure_compare signature, expected_signature
end
# Allow for 2 minutes of drift between Mailgun time and local server time.
- 1
def recent?
- 3
Time.at(timestamp) >= 2.minutes.ago
end
- 1
def expected_signature
- 4
OpenSSL::HMAC.hexdigest OpenSSL::Digest::SHA256.new, key, "#{timestamp}#{token}"
end
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# Ingests inbound emails from Mandrill.
#
# Requires a +mandrill_events+ parameter containing a JSON array of Mandrill inbound email event objects.
# Each event is expected to have a +msg+ object containing a full RFC 822 message in its +raw_msg+ property.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request's signature could not be validated
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Mandrill
# - <tt>422 Unprocessable Entity</tt> if the request is missing required parameters
# - <tt>500 Server Error</tt> if the Mandrill API key is missing, or one of the Active Record database,
# the Active Storage service, or the Active Job backend is misconfigured or unavailable
- 1
class Ingresses::Mandrill::InboundEmailsController < ActionMailbox::BaseController
- 1
before_action :authenticate, except: :health_check
- 1
def create
- 2
raw_emails.each { |raw_email| ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email }
- 1
head :ok
rescue JSON::ParserError => error
logger.error error.message
head :unprocessable_entity
end
- 1
def health_check
- 1
head :ok
end
- 1
private
- 1
def raw_emails
- 3
events.select { |event| event["event"] == "inbound" }.collect { |event| event.dig("msg", "raw_msg") }
end
- 1
def events
- 1
JSON.parse params.require(:mandrill_events)
end
- 1
def authenticate
- 4
head :unauthorized unless authenticated?
end
- 1
def authenticated?
- 4
if key.present?
- 2
Authenticator.new(request, key).authenticated?
else
- 2
raise ArgumentError, <<~MESSAGE.squish
Missing required Mandrill API key. Set action_mailbox.mandrill_api_key in your application's
encrypted credentials or provide the MANDRILL_INGRESS_API_KEY environment variable.
MESSAGE
end
end
- 1
def key
- 6
Rails.application.credentials.dig(:action_mailbox, :mandrill_api_key) || ENV["MANDRILL_INGRESS_API_KEY"]
end
- 1
class Authenticator
- 1
attr_reader :request, :key
- 1
def initialize(request, key)
- 2
@request, @key = request, key
end
- 1
def authenticated?
- 2
ActiveSupport::SecurityUtils.secure_compare given_signature, expected_signature
end
- 1
private
- 1
def given_signature
- 2
request.headers["X-Mandrill-Signature"]
end
- 1
def expected_signature
- 2
Base64.strict_encode64 OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, key, message)
end
- 1
def message
- 2
request.url + request.POST.sort.flatten.join
end
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# Ingests inbound emails from Postmark. Requires a +RawEmail+ parameter containing a full RFC 822 message.
#
# Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the
# password is read from the application's encrypted credentials or an environment variable. See the Usage section below.
#
# Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to
# the Postmark ingress can learn its password. You should only use the Postmark ingress over HTTPS.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request's signature could not be validated
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Postmark
# - <tt>422 Unprocessable Entity</tt> if the request is missing the required +RawEmail+ parameter
# - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database,
# the Active Storage service, or the Active Job backend is misconfigured or unavailable
#
# == Usage
#
# 1. Tell Action Mailbox to accept emails from Postmark:
#
# # config/environments/production.rb
# config.action_mailbox.ingress = :postmark
#
# 2. Generate a strong password that Action Mailbox can use to authenticate requests to the Postmark ingress.
#
# Use <tt>bin/rails credentials:edit</tt> to add the password to your application's encrypted credentials under
# +action_mailbox.ingress_password+, where Action Mailbox will automatically find it:
#
# action_mailbox:
# ingress_password: ...
#
# Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable.
#
# 3. {Configure Postmark}[https://postmarkapp.com/manual#configure-your-inbound-webhook-url] to forward inbound emails
# to +/rails/action_mailbox/postmark/inbound_emails+ with the username +actionmailbox+ and the password you
# previously generated. If your application lived at <tt>https://example.com</tt>, you would configure your
# Postmark inbound webhook with the following fully-qualified URL:
#
# https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/postmark/inbound_emails
#
# *NOTE:* When configuring your Postmark inbound webhook, be sure to check the box labeled *"Include raw email
# content in JSON payload"*. Action Mailbox needs the raw email content to work.
- 1
class Ingresses::Postmark::InboundEmailsController < ActionMailbox::BaseController
- 1
before_action :authenticate_by_password
- 1
def create
- 2
ActionMailbox::InboundEmail.create_and_extract_message_id! params.require("RawEmail")
rescue ActionController::ParameterMissing => error
- 1
logger.error <<~MESSAGE
#{error.message}
When configuring your Postmark inbound webhook, be sure to check the box
labeled "Include raw email content in JSON payload".
MESSAGE
- 1
head :unprocessable_entity
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# Ingests inbound emails relayed from an SMTP server.
#
# Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the
# password is read from the application's encrypted credentials or an environment variable. See the Usage section below.
#
# Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to
# the ingress can learn its password. You should only use this ingress over HTTPS.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request could not be authenticated
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails relayed from an SMTP server
# - <tt>415 Unsupported Media Type</tt> if the request does not contain an RFC 822 message
# - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database,
# the Active Storage service, or the Active Job backend is misconfigured or unavailable
#
# == Usage
#
# 1. Tell Action Mailbox to accept emails from an SMTP relay:
#
# # config/environments/production.rb
# config.action_mailbox.ingress = :relay
#
# 2. Generate a strong password that Action Mailbox can use to authenticate requests to the ingress.
#
# Use <tt>bin/rails credentials:edit</tt> to add the password to your application's encrypted credentials under
# +action_mailbox.ingress_password+, where Action Mailbox will automatically find it:
#
# action_mailbox:
# ingress_password: ...
#
# Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable.
#
# 3. Configure your SMTP server to pipe inbound emails to the appropriate ingress command, providing the +URL+ of the
# relay ingress and the +INGRESS_PASSWORD+ you previously generated.
#
# If your application lives at <tt>https://example.com</tt>, you would configure the Postfix SMTP server to pipe
# inbound emails to the following command:
#
# bin/rails action_mailbox:ingress:postfix URL=https://example.com/rails/action_mailbox/postfix/inbound_emails INGRESS_PASSWORD=...
#
# Built-in ingress commands are available for these popular SMTP servers:
#
# - Exim (<tt>bin/rails action_mailbox:ingress:exim)
# - Postfix (<tt>bin/rails action_mailbox:ingress:postfix)
# - Qmail (<tt>bin/rails action_mailbox:ingress:qmail)
- 1
class Ingresses::Relay::InboundEmailsController < ActionMailbox::BaseController
- 1
before_action :authenticate_by_password, :require_valid_rfc822_message
- 1
def create
- 1
ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read
end
- 1
private
- 1
def require_valid_rfc822_message
- 2
unless request.content_type == "message/rfc822"
- 1
head :unsupported_media_type
end
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# Ingests inbound emails from SendGrid. Requires an +email+ parameter containing a full RFC 822 message.
#
# Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the
# password is read from the application's encrypted credentials or an environment variable. See the Usage section below.
#
# Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to
# the SendGrid ingress can learn its password. You should only use the SendGrid ingress over HTTPS.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request's signature could not be validated
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from SendGrid
# - <tt>422 Unprocessable Entity</tt> if the request is missing the required +email+ parameter
# - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database,
# the Active Storage service, or the Active Job backend is misconfigured or unavailable
#
# == Usage
#
# 1. Tell Action Mailbox to accept emails from SendGrid:
#
# # config/environments/production.rb
# config.action_mailbox.ingress = :sendgrid
#
# 2. Generate a strong password that Action Mailbox can use to authenticate requests to the SendGrid ingress.
#
# Use <tt>bin/rails credentials:edit</tt> to add the password to your application's encrypted credentials under
# +action_mailbox.ingress_password+, where Action Mailbox will automatically find it:
#
# action_mailbox:
# ingress_password: ...
#
# Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable.
#
# 3. {Configure SendGrid Inbound Parse}[https://sendgrid.com/docs/for-developers/parsing-email/setting-up-the-inbound-parse-webhook/]
# to forward inbound emails to +/rails/action_mailbox/sendgrid/inbound_emails+ with the username +actionmailbox+ and
# the password you previously generated. If your application lived at <tt>https://example.com</tt>, you would
# configure SendGrid with the following fully-qualified URL:
#
# https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/sendgrid/inbound_emails
#
# *NOTE:* When configuring your SendGrid Inbound Parse webhook, be sure to check the box labeled *"Post the raw,
# full MIME message."* Action Mailbox needs the raw MIME message to work.
- 1
class Ingresses::Sendgrid::InboundEmailsController < ActionMailbox::BaseController
- 1
before_action :authenticate_by_password
- 1
def create
- 2
ActionMailbox::InboundEmail.create_and_extract_message_id! mail
rescue JSON::ParserError => error
logger.error error.message
head :unprocessable_entity
end
- 1
private
- 1
def mail
- 2
params.require(:email).tap do |raw_email|
- 3
envelope["to"].each { |to| raw_email.prepend("X-Original-To: ", to, "\n") } if params.key?(:envelope)
end
end
- 1
def envelope
- 1
JSON.parse(params.require(:envelope))
end
end
end
# frozen_string_literal: true
- 1
module Rails
- 1
class Conductor::ActionMailbox::InboundEmailsController < Rails::Conductor::BaseController
- 1
def index
@inbound_emails = ActionMailbox::InboundEmail.order(created_at: :desc)
end
- 1
def new
end
- 1
def show
@inbound_email = ActionMailbox::InboundEmail.find(params[:id])
end
- 1
def create
- 3
inbound_email = create_inbound_email(new_mail)
- 3
redirect_to main_app.rails_conductor_inbound_email_url(inbound_email)
end
- 1
private
- 1
def new_mail
- 3
Mail.new(params.require(:mail).permit(:from, :to, :cc, :bcc, :x_original_to, :in_reply_to, :subject, :body).to_h).tap do |mail|
- 3
mail[:bcc]&.include_in_headers = true
- 3
params[:mail][:attachments].to_a.each do |attachment|
- 2
mail.add_file(filename: attachment.original_filename, content: attachment.read)
end
end
end
- 1
def create_inbound_email(mail)
- 3
ActionMailbox::InboundEmail.create_and_extract_message_id!(mail.to_s)
end
end
end
# frozen_string_literal: true
- 1
module Rails
# TODO: Move this to Rails::Conductor gem
- 1
class Conductor::BaseController < ActionController::Base
- 1
layout "rails/conductor"
- 1
before_action :ensure_development_env
- 1
private
- 1
def ensure_development_env
- 3
head :forbidden unless Rails.env.development?
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# You can configure when this +IncinerationJob+ will be run as a time-after-processing using the
# +config.action_mailbox.incinerate_after+ or +ActionMailbox.incinerate_after+ setting.
#
# Since this incineration is set for the future, it'll automatically ignore any <tt>InboundEmail</tt>s
# that have already been deleted and discard itself if so.
#
# You can disable incinerating processed emails by setting +config.action_mailbox.incinerate+ or
# +ActionMailbox.incinerate+ to +false+.
- 1
class IncinerationJob < ActiveJob::Base
- 29
queue_as { ActionMailbox.queues[:incineration] }
- 1
discard_on ActiveRecord::RecordNotFound
- 1
def self.schedule(inbound_email)
- 27
set(wait: ActionMailbox.incinerate_after).perform_later(inbound_email)
end
- 1
def perform(inbound_email)
- 3
inbound_email.incinerate
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# Routing a new InboundEmail is an asynchronous operation, which allows the ingress controllers to quickly
# accept new incoming emails without being burdened to hang while they're actually being processed.
- 1
class RoutingJob < ActiveJob::Base
- 13
queue_as { ActionMailbox.queues[:routing] }
- 1
def perform(inbound_email)
- 1
inbound_email.route
end
end
end
# frozen_string_literal: true
- 1
require "mail"
- 1
module ActionMailbox
# The +InboundEmail+ is an Active Record that keeps a reference to the raw email stored in Active Storage
# and tracks the status of processing. By default, incoming emails will go through the following lifecycle:
#
# * Pending: Just received by one of the ingress controllers and scheduled for routing.
# * Processing: During active processing, while a specific mailbox is running its #process method.
# * Delivered: Successfully processed by the specific mailbox.
# * Failed: An exception was raised during the specific mailbox's execution of the +#process+ method.
# * Bounced: Rejected processing by the specific mailbox and bounced to sender.
#
# Once the +InboundEmail+ has reached the status of being either +delivered+, +failed+, or +bounced+,
# it'll count as having been +#processed?+. Once processed, the +InboundEmail+ will be scheduled for
# automatic incineration at a later point.
#
# When working with an +InboundEmail+, you'll usually interact with the parsed version of the source,
# which is available as a +Mail+ object from +#mail+. But you can also access the raw source directly
# using the +#source+ method.
#
# Examples:
#
# inbound_email.mail.from # => 'david@loudthinking.com'
# inbound_email.source # Returns the full rfc822 source of the email as text
- 1
class InboundEmail < ActiveRecord::Base
- 1
self.table_name = "action_mailbox_inbound_emails"
- 1
include Incineratable, MessageId, Routable
- 1
has_one_attached :raw_email
- 1
enum status: %i[ pending processing delivered failed bounced ]
- 1
def mail
- 50
@mail ||= Mail.from_source(source)
end
- 1
def source
- 29
@source ||= raw_email.download
end
- 1
def processed?
- 31
delivered? || failed? || bounced?
end
end
end
- 1
ActiveSupport.run_load_hooks :action_mailbox_inbound_email, ActionMailbox::InboundEmail
# frozen_string_literal: true
# Ensure that the +InboundEmail+ is automatically scheduled for later incineration if the status has been
# changed to +processed+. The later incineration will be invoked at the time specified by the
# +ActionMailbox.incinerate_after+ time using the +IncinerationJob+.
- 1
module ActionMailbox::InboundEmail::Incineratable
- 1
extend ActiveSupport::Concern
- 1
included do
- 53
after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? }
end
- 1
def incinerate_later
- 27
ActionMailbox::IncinerationJob.schedule self
end
- 1
def incinerate
- 3
Incineration.new(self).run
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# Command class for carrying out the actual incineration of the +InboundMail+ that's been scheduled
# for removal. Before the incineration – which really is just a call to +#destroy!+ – is run, we verify
# that it's both eligible (by virtue of having already been processed) and time to do so (that is,
# the +InboundEmail+ was processed after the +incinerate_after+ time).
- 1
class InboundEmail::Incineratable::Incineration
- 1
def initialize(inbound_email)
- 3
@inbound_email = inbound_email
end
- 1
def run
- 3
@inbound_email.destroy! if due? && processed?
end
- 1
private
- 1
def due?
- 3
@inbound_email.updated_at < ActionMailbox.incinerate_after.ago.end_of_day
end
- 1
def processed?
- 3
@inbound_email.processed?
end
end
end
# frozen_string_literal: true
# The +Message-ID+ as specified by rfc822 is supposed to be a unique identifier for that individual email.
# That makes it an ideal tracking token for debugging and forensics, just like +X-Request-Id+ does for
# web request.
#
# If an inbound email does not, against the rfc822 mandate, specify a Message-ID, one will be generated
# using the approach from <tt>Mail::MessageIdField</tt>.
- 1
module ActionMailbox::InboundEmail::MessageId
- 1
extend ActiveSupport::Concern
- 1
class_methods do
# Create a new +InboundEmail+ from the raw +source+ of the email, which is uploaded as an Active Storage
# attachment called +raw_email+. Before the upload, extract the Message-ID from the +source+ and set
# it as an attribute on the new +InboundEmail+.
- 1
def create_and_extract_message_id!(source, **options)
- 50
message_checksum = Digest::SHA1.hexdigest(source)
- 50
message_id = extract_message_id(source) || generate_missing_message_id(message_checksum)
- 50
create! options.merge(message_id: message_id, message_checksum: message_checksum) do |inbound_email|
- 50
inbound_email.raw_email.attach io: StringIO.new(source), filename: "message.eml", content_type: "message/rfc822"
end
rescue ActiveRecord::RecordNotUnique
- 2
nil
end
- 1
private
- 51
def extract_message_id(source)
Mail.from_source(source).message_id rescue nil
end
- 1
def generate_missing_message_id(message_checksum)
- 1
Mail::MessageIdField.new("<#{message_checksum}@#{::Socket.gethostname}.mail>").message_id.tap do |message_id|
- 1
logger.warn "Message-ID couldn't be parsed or is missing. Generated a new Message-ID: #{message_id}"
end
end
end
end
# frozen_string_literal: true
# A newly received +InboundEmail+ will not be routed synchronously as part of ingress controller's receival.
# Instead, the routing will be done asynchronously, using a +RoutingJob+, to ensure maximum parallel capacity.
#
# By default, all newly created +InboundEmail+ records that have the status of +pending+, which is the default,
# will be scheduled for automatic, deferred routing.
- 1
module ActionMailbox::InboundEmail::Routable
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
after_create_commit :route_later, if: :pending?
end
# Enqueue a +RoutingJob+ for this +InboundEmail+.
- 1
def route_later
- 12
ActionMailbox::RoutingJob.perform_later self
end
# Route this +InboundEmail+ using the routing rules declared on the +ApplicationMailbox+.
- 1
def route
- 2
ApplicationMailbox.route self
end
end
# frozen_string_literal: true
- 1
Rails.application.routes.draw do
- 1
scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do
- 1
post "/postmark/inbound_emails" => "postmark/inbound_emails#create", as: :rails_postmark_inbound_emails
- 1
post "/relay/inbound_emails" => "relay/inbound_emails#create", as: :rails_relay_inbound_emails
- 1
post "/sendgrid/inbound_emails" => "sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails
# Mandrill checks for the existence of a URL with a HEAD request before it will create the webhook.
- 1
get "/mandrill/inbound_emails" => "mandrill/inbound_emails#health_check", as: :rails_mandrill_inbound_health_check
- 1
post "/mandrill/inbound_emails" => "mandrill/inbound_emails#create", as: :rails_mandrill_inbound_emails
# Mailgun requires that a webhook's URL end in 'mime' for it to receive the raw contents of emails.
- 1
post "/mailgun/inbound_emails/mime" => "mailgun/inbound_emails#create", as: :rails_mailgun_inbound_emails
end
# TODO: Should these be mounted within the engine only?
- 1
scope "rails/conductor/action_mailbox/", module: "rails/conductor/action_mailbox" do
- 1
resources :inbound_emails, as: :rails_conductor_inbound_emails
- 1
get "inbound_emails/sources/new", to: "inbound_emails/sources#new", as: :new_rails_conductor_inbound_email_source
- 1
post "inbound_emails/sources", to: "inbound_emails/sources#create", as: :rails_conductor_inbound_email_sources
- 1
post ":inbound_email_id/reroute" => "reroutes#create", as: :rails_conductor_inbound_email_reroute
end
end
# frozen_string_literal: true
- 1
require "action_mailbox/mail_ext"
- 1
module ActionMailbox
- 1
extend ActiveSupport::Autoload
- 1
autoload :Base
- 1
autoload :Router
- 1
autoload :TestCase
- 1
mattr_accessor :ingress
- 1
mattr_accessor :logger
- 1
mattr_accessor :incinerate, default: true
- 1
mattr_accessor :incinerate_after, default: 30.days
- 1
mattr_accessor :queues, default: {}
end
# frozen_string_literal: true
- 1
require "active_support/rescuable"
- 1
require "action_mailbox/callbacks"
- 1
require "action_mailbox/routing"
- 1
module ActionMailbox
# 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
- 1
class Base
- 1
include ActiveSupport::Rescuable
- 1
include ActionMailbox::Callbacks, ActionMailbox::Routing
- 1
attr_reader :inbound_email
- 1
delegate :mail, :delivered!, :bounced!, to: :inbound_email
- 1
delegate :logger, to: ActionMailbox
- 1
def self.receive(inbound_email)
- 23
new(inbound_email).perform_processing
end
- 1
def initialize(inbound_email)
- 23
@inbound_email = inbound_email
end
- 1
def perform_processing #:nodoc:
- 23
track_status_of_inbound_email do
- 23
run_callbacks :process do
- 21
process
end
end
rescue => exception
# TODO: Include a reference to the inbound_email in the exception raised so error handling becomes easier
- 1
rescue_with_handler(exception) || raise
end
- 1
def process
# Overwrite in subclasses
end
- 1
def finished_processing? #:nodoc:
- 5
inbound_email.delivered? || inbound_email.bounced?
end
# Enqueues the given +message+ for delivery and changes the inbound email's status to +:bounced+.
- 1
def bounce_with(message)
- 2
inbound_email.bounced!
- 2
message.deliver_later
end
- 1
private
- 1
def track_status_of_inbound_email
- 23
inbound_email.processing!
- 23
yield
- 22
inbound_email.delivered! unless inbound_email.bounced?
rescue
- 1
inbound_email.failed!
- 1
raise
end
end
end
- 1
ActiveSupport.run_load_hooks :action_mailbox, ActionMailbox::Base
# frozen_string_literal: true
- 1
require "active_support/callbacks"
- 1
module ActionMailbox
# Defines the callbacks related to processing.
- 1
module Callbacks
- 1
extend ActiveSupport::Concern
- 1
include ActiveSupport::Callbacks
- 1
TERMINATOR = ->(mailbox, chain) do
- 5
chain.call
- 5
mailbox.finished_processing?
end
- 1
included do
- 1
define_callbacks :process, terminator: TERMINATOR, skip_after_callbacks_if_terminated: true
end
- 1
class_methods do
- 1
def before_processing(*methods, &block)
- 7
set_callback(:process, :before, *methods, &block)
end
- 1
def after_processing(*methods, &block)
- 3
set_callback(:process, :after, *methods, &block)
end
- 1
def around_processing(*methods, &block)
- 1
set_callback(:process, :around, *methods, &block)
end
end
end
end
# frozen_string_literal: true
- 1
require "rails"
- 1
require "action_controller/railtie"
- 1
require "active_job/railtie"
- 1
require "active_record/railtie"
- 1
require "active_storage/engine"
- 1
require "action_mailbox"
- 1
module ActionMailbox
- 1
class Engine < Rails::Engine
- 1
isolate_namespace ActionMailbox
- 1
config.eager_load_namespaces << ActionMailbox
- 1
config.action_mailbox = ActiveSupport::OrderedOptions.new
- 1
config.action_mailbox.incinerate = true
- 1
config.action_mailbox.incinerate_after = 30.days
- 1
config.action_mailbox.queues = ActiveSupport::InheritableOptions.new \
incineration: :action_mailbox_incineration, routing: :action_mailbox_routing
- 1
initializer "action_mailbox.config" do
- 1
config.after_initialize do |app|
- 1
ActionMailbox.logger = app.config.action_mailbox.logger || Rails.logger
- 1
ActionMailbox.incinerate = app.config.action_mailbox.incinerate.nil? ? true : app.config.action_mailbox.incinerate
- 1
ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days
- 1
ActionMailbox.queues = app.config.action_mailbox.queues || {}
- 1
ActionMailbox.ingress = app.config.action_mailbox.ingress
end
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# Returns the currently-loaded version of Action Mailbox as a <tt>Gem::Version</tt>.
- 1
def self.gem_version
- 1
Gem::Version.new VERSION::STRING
end
- 1
module VERSION
- 1
MAJOR = 6
- 1
MINOR = 1
- 1
TINY = 0
- 1
PRE = "alpha"
- 1
STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
end
end
# frozen_string_literal: true
- 1
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!
- 6
Dir["#{File.expand_path(File.dirname(__FILE__))}/mail_ext/*"].each { |path| require "action_mailbox/mail_ext/#{File.basename(path)}" }
# frozen_string_literal: true
- 1
module Mail
- 1
class Address
- 1
def ==(other_address)
- 1
other_address.is_a?(Mail::Address) && to_s == other_address.to_s
end
end
end
# frozen_string_literal: true
- 1
module Mail
- 1
class Address
- 1
def self.wrap(address)
- 2
address.is_a?(Mail::Address) ? address : Mail::Address.new(address)
end
end
end
# frozen_string_literal: true
- 1
module Mail
- 1
class Message
- 1
def from_address
- 1
header[:from]&.address_list&.addresses&.first
end
- 1
def recipients_addresses
- 1
to_addresses + cc_addresses + bcc_addresses + x_original_to_addresses
end
- 1
def to_addresses
- 2
Array(header[:to]&.address_list&.addresses)
end
- 1
def cc_addresses
- 2
Array(header[:cc]&.address_list&.addresses)
end
- 1
def bcc_addresses
- 2
Array(header[:bcc]&.address_list&.addresses)
end
- 1
def x_original_to_addresses
- 4
Array(header[:x_original_to]).collect { |header| Mail::Address.new header.to_s }
end
end
end
# frozen_string_literal: true
- 1
module Mail
- 1
def self.from_source(source)
- 82
Mail.new Mail::Utilities.binary_unsafe_to_crlf(source.to_s)
end
end
# frozen_string_literal: true
- 1
module Mail
- 1
class Message
- 1
def recipients
- 16
Array(to) + Array(cc) + Array(bcc) + Array(header[:x_original_to]).map(&:to_s)
end
end
end
# frozen_string_literal: true
- 1
require "action_mailbox/version"
- 1
require "net/http"
- 1
require "uri"
- 1
module ActionMailbox
- 1
class Relayer
- 1
class Result < Struct.new(:status_code, :message)
- 1
def success?
- 8
!failure?
end
- 1
def failure?
- 16
transient_failure? || permanent_failure?
end
- 1
def transient_failure?
- 16
status_code.start_with?("4.")
end
- 1
def permanent_failure?
- 2
status_code.start_with?("5.")
end
end
- 1
CONTENT_TYPE = "message/rfc822"
- 1
USER_AGENT = "Action Mailbox relayer v#{ActionMailbox.version}"
- 1
attr_reader :uri, :username, :password
- 1
def initialize(url:, username: "actionmailbox", password:)
- 8
@uri, @username, @password = URI(url), username, password
end
- 1
def relay(source)
- 8
case response = post(source)
when Net::HTTPSuccess
- 1
Result.new "2.0.0", "Successfully relayed message to ingress"
when Net::HTTPUnauthorized
- 1
Result.new "4.7.0", "Invalid credentials for ingress"
else
- 2
Result.new "4.0.0", "HTTP #{response.code}"
end
rescue IOError, SocketError, SystemCallError => error
- 2
Result.new "4.4.2", "Network error relaying to ingress: #{error.message}"
rescue Timeout::Error
- 1
Result.new "4.4.2", "Timed out relaying to ingress"
rescue => error
- 1
Result.new "4.0.0", "Error relaying to ingress: #{error.message}"
end
- 1
private
- 1
def post(source)
- 8
client.post uri, source,
"Content-Type" => CONTENT_TYPE,
"User-Agent" => USER_AGENT,
"Authorization" => "Basic #{Base64.strict_encode64(username + ":" + password)}"
end
- 1
def client
- 8
@client ||= Net::HTTP.new(uri.host, uri.port).tap do |connection|
- 8
if uri.scheme == "https"
- 8
require "openssl"
- 8
connection.use_ssl = true
- 8
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
end
- 8
connection.open_timeout = 1
- 8
connection.read_timeout = 10
end
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# Encapsulates the routes that live on the ApplicationMailbox and performs the actual routing when
# an inbound_email is received.
- 1
class Router
- 1
class RoutingError < StandardError; end
- 1
def initialize
- 16
@routes = []
end
- 1
def add_routes(routes)
- 9
routes.each do |(address, mailbox_name)|
- 11
add_route address, to: mailbox_name
end
end
- 1
def add_route(address, to:)
- 18
routes.append Route.new(address, to: to)
end
- 1
def route(inbound_email)
- 17
if mailbox = mailbox_for(inbound_email)
- 16
mailbox.receive(inbound_email)
else
- 1
inbound_email.bounced!
- 1
raise RoutingError
end
end
- 1
def mailbox_for(inbound_email)
- 41
routes.detect { |route| route.match?(inbound_email) }&.mailbox_class
end
- 1
private
- 1
attr_reader :routes
end
end
- 1
require "action_mailbox/router/route"
# frozen_string_literal: true
- 1
module ActionMailbox
# 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.
- 1
class Router::Route
- 1
attr_reader :address, :mailbox_name
- 1
def initialize(address, to:)
- 18
@address, @mailbox_name = address, to
- 18
ensure_valid_address
end
- 1
def match?(inbound_email)
- 21
case address
when :all
- 2
true
when String
- 30
inbound_email.mail.recipients.any? { |recipient| address.casecmp?(recipient) }
when Regexp
- 2
inbound_email.mail.recipients.any? { |recipient| address.match?(recipient) }
when Proc
- 1
address.call(inbound_email)
else
- 3
address.match?(inbound_email)
end
end
- 1
def mailbox_class
- 18
"#{mailbox_name.to_s.camelize}Mailbox".constantize
end
- 1
private
- 1
def ensure_valid_address
- 61
unless [ Symbol, String, Regexp, Proc ].any? { |klass| address.is_a?(klass) } || address.respond_to?(:match?)
- 1
raise ArgumentError, "Expected a Symbol, String, Regexp, Proc, or matchable, got #{address.inspect}"
end
end
end
end
# frozen_string_literal: true
- 1
module ActionMailbox
# See +ActionMailbox::Base+ for how to specify routing.
- 1
module Routing
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
cattr_accessor :router, default: ActionMailbox::Router.new
end
- 1
class_methods do
- 1
def routing(routes)
- 1
router.add_routes(routes)
end
- 1
def route(inbound_email)
- 3
router.route(inbound_email)
end
- 1
def mailbox_for(inbound_email)
- 1
router.mailbox_for(inbound_email)
end
end
end
end
# 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
ActiveSupport.run_load_hooks :action_mailbox_test_case, ActionMailbox::TestCase
# frozen_string_literal: true
- 1
require "mail"
- 1
module ActionMailbox
- 1
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+.
- 1
def create_inbound_email_from_fixture(fixture_name, status: :processing)
- 14
create_inbound_email_from_source file_fixture(fixture_name).read, status: status
end
# Creates an +InboundEmail+ by specifying through options or a block.
#
# ==== Options
#
# * <tt>:status</tt> - The +status+ to set for the created +InboundEmail+.
# For possible statuses, see {its documentation}[rdoc-ref:ActionMailbox::InboundEmail].
#
# ==== Creating a simple email
#
# When you only need to set basic fields like +from+, +to+, +subject+, and
# +body+, you can pass them directly as options.
#
# create_inbound_email_from_mail(from: "david@loudthinking.com", subject: "Hello!")
#
# ==== Creating a multi-part email
#
# When you need to create a more intricate email, like a multi-part email
# that contains both a plaintext version and an HTML version, you can pass a
# block.
#
# create_inbound_email_from_mail do
# to "David Heinemeier Hansson <david@loudthinking.com>"
# from "Bilbo Baggins <bilbo@bagend.com>"
# subject "Come down to the Shire!"
#
# text_part do
# body "Please join us for a party at Bag End"
# end
#
# html_part do
# body "<h1>Please join us for a party at Bag End</h1>"
# end
# end
#
# As with +Mail.new+, you can also use a block parameter to define the parts
# of the message:
#
# create_inbound_email_from_mail do |mail|
# mail.to "David Heinemeier Hansson <david@loudthinking.com>"
# mail.from "Bilbo Baggins <bilbo@bagend.com>"
# mail.subject "Come down to the Shire!"
#
# mail.text_part do |part|
# part.body "Please join us for a party at Bag End"
# end
#
# mail.html_part do |part|
# part.body "<h1>Please join us for a party at Bag End</h1>"
# end
# end
- 1
def create_inbound_email_from_mail(status: :processing, **mail_options, &block)
- 21
mail = Mail.new(mail_options, &block)
# Bcc header is not encoded by default
- 21
mail[:bcc].include_in_headers = true if mail[:bcc]
- 21
create_inbound_email_from_source mail.to_s, status: status
end
# Create an +InboundEmail+ using the raw rfc822 +source+ as text.
- 1
def create_inbound_email_from_source(source, status: :processing)
- 40
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.
- 1
def receive_inbound_email_from_fixture(*args)
- 1
create_inbound_email_from_fixture(*args).tap(&:route)
end
# Create an +InboundEmail+ using the same options or block as
# {create_inbound_email_from_mail}[rdoc-ref:#create_inbound_email_from_mail],
# then immediately route it for processing.
- 1
def receive_inbound_email_from_mail(**kwargs, &block)
create_inbound_email_from_mail(**kwargs, &block).tap(&:route)
end
# Create an +InboundEmail+ using the same arguments as +create_inbound_email_from_source+ and immediately route it
# to processing.
- 1
def receive_inbound_email_from_source(*args)
create_inbound_email_from_source(*args).tap(&:route)
end
end
end
# frozen_string_literal: true
- 1
require_relative "gem_version"
- 1
module ActionMailbox
# Returns the currently-loaded version of Action Mailbox as a <tt>Gem::Version</tt>.
- 1
def self.version
- 1
gem_version
end
end
# frozen_string_literal: true
require "rails/generators/mailbox/mailbox_generator"
module ActionMailbox
module Generators
class InstallGenerator < ::Rails::Generators::Base
source_root Rails::Generators::MailboxGenerator.source_root
def create_action_mailbox_files
say "Copying application_mailbox.rb to app/mailboxes", :green
template "application_mailbox.rb", "app/mailboxes/application_mailbox.rb"
end
def add_action_mailbox_production_environment_config
environment <<~end_of_config, env: "production"
# Prepare the ingress controller used to receive mail
# config.action_mailbox.ingress = :relay
end_of_config
end
def create_migrations
rails_command "railties:install:migrations FROM=active_storage,action_mailbox", inline: true
end
end
end
end
# frozen_string_literal: true
- 1
module Rails
- 1
module Generators
- 1
class MailboxGenerator < NamedBase
- 1
source_root File.expand_path("templates", __dir__)
- 1
check_class_collision suffix: "Mailbox"
- 1
def create_mailbox_file
- 6
template "mailbox.rb", File.join("app/mailboxes", class_path, "#{file_name}_mailbox.rb")
- 6
in_root do
- 6
if behavior == :invoke && !File.exist?(application_mailbox_file_name)
- 5
template "application_mailbox.rb", application_mailbox_file_name
end
end
end
- 1
hook_for :test_framework
- 1
private
- 1
def file_name # :doc:
- 18
@_file_name ||= super.sub(/_mailbox\z/i, "")
end
- 1
def application_mailbox_file_name
- 10
"app/mailboxes/application_mailbox.rb"
end
end
end
end
# frozen_string_literal: true
- 1
module TestUnit
- 1
module Generators
- 1
class MailboxGenerator < ::Rails::Generators::NamedBase
- 1
source_root File.expand_path("templates", __dir__)
- 1
check_class_collision suffix: "MailboxTest"
- 1
def create_test_files
- 3
template "mailbox_test.rb", File.join("test/mailboxes", class_path, "#{file_name}_mailbox_test.rb")
end
- 1
private
- 1
def file_name # :doc:
- 9
@_file_name ||= super.sub(/_mailbox\z/i, "")
end
end
end
end