diff --git a/Gemfile b/Gemfile index 65f1a6f190..bb5cba25a7 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,5 @@ git_source(:github) { |repo_path| "https://github.com/#{repo_path}.git" } gemspec gem "rails", github: "rails/rails" + +gem "aws-sdk-sns" diff --git a/Gemfile.lock b/Gemfile.lock index d01cb7bd76..402a6e8a10 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -66,20 +66,46 @@ PATH remote: . specs: actionmailbox (0.1.0) + http (>= 4.0.0) rails (>= 5.2.0) GEM remote: https://rubygems.org/ specs: + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) + aws-eventstream (1.0.1) + aws-partitions (1.105.0) + aws-sdk-core (3.30.0) + aws-eventstream (~> 1.0) + aws-partitions (~> 1.0) + aws-sigv4 (~> 1.0) + jmespath (~> 1.0) + aws-sdk-sns (1.5.0) + aws-sdk-core (~> 3, >= 3.26.0) + aws-sigv4 (~> 1.0) + aws-sigv4 (1.0.3) builder (3.2.3) byebug (10.0.2) concurrent-ruby (1.0.5) crass (1.0.4) + domain_name (0.5.20180417) + unf (>= 0.0.5, < 1.0.0) erubi (1.7.1) globalid (0.4.1) activesupport (>= 4.2.0) + http (4.0.0) + addressable (~> 2.3) + http-cookie (~> 1.0) + http-form_data (~> 2.0) + http_parser.rb (~> 0.6.0) + http-cookie (1.0.3) + domain_name (~> 0.5) + http-form_data (2.1.1) + http_parser.rb (0.6.0) i18n (1.1.0) concurrent-ruby (~> 1.0) + jmespath (1.4.0) loofah (2.2.2) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -95,6 +121,7 @@ GEM nio4r (2.3.1) nokogiri (1.8.5) mini_portile2 (~> 2.3.0) + public_suffix (3.0.3) rack (2.0.5) rack-test (1.1.0) rack (>= 1.0, < 3) @@ -116,6 +143,9 @@ GEM thread_safe (0.3.6) tzinfo (1.2.5) thread_safe (~> 0.1) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.5) websocket-driver (0.7.0) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) @@ -125,6 +155,7 @@ PLATFORMS DEPENDENCIES actionmailbox! + aws-sdk-sns bundler (~> 1.15) byebug rails! diff --git a/actionmailbox.gemspec b/actionmailbox.gemspec index e413ac57f6..e3890ab574 100644 --- a/actionmailbox.gemspec +++ b/actionmailbox.gemspec @@ -16,6 +16,7 @@ Gem::Specification.new do |s| s.required_ruby_version = ">= 2.5.0" s.add_dependency "rails", ">= 5.2.0" + s.add_dependency "http", ">= 4.0.0" s.add_development_dependency "bundler", "~> 1.15" s.add_development_dependency "sqlite3" diff --git a/app/controllers/action_mailbox/base_controller.rb b/app/controllers/action_mailbox/base_controller.rb new file mode 100644 index 0000000000..c234ecd250 --- /dev/null +++ b/app/controllers/action_mailbox/base_controller.rb @@ -0,0 +1,42 @@ +class ActionMailbox::BaseController < ActionController::Base + skip_forgery_protection + + def self.prepare + # Override in concrete controllers to run code on load. + end + + before_action :ensure_configured + + private + def ensure_configured + unless ActionMailbox.ingress == ingress_name + head :not_found + end + end + + def ingress_name + self.class.name[/^ActionMailbox::Ingresses::(.*?)::/, 1].underscore.to_sym + end + + + def authenticate_by_password + if password.present? + http_basic_authenticate_or_request_with username: "actionmailbox", password: password, realm: "Action Mailbox" + else + raise ArgumentError, "Missing required ingress credentials" + end + end + + def password + Rails.application.credentials.dig(:action_mailbox, :ingress_password) || ENV["RAILS_INBOUND_EMAIL_PASSWORD"] + end + + + # TODO: Extract to ActionController::HttpAuthentication + def http_basic_authenticate_or_request_with(username:, password:, realm: nil) + authenticate_or_request_with_http_basic(realm || "Application") do |given_username, given_password| + ActiveSupport::SecurityUtils.secure_compare(given_username, username) & + ActiveSupport::SecurityUtils.secure_compare(given_password, password) + end + end +end diff --git a/app/controllers/action_mailbox/inbound_emails_controller.rb b/app/controllers/action_mailbox/inbound_emails_controller.rb deleted file mode 100644 index ec9bd6f229..0000000000 --- a/app/controllers/action_mailbox/inbound_emails_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# TODO: Add access protection using basic auth with verified tokens. Maybe coming from credentials by default? -# TODO: Spam/malware catching? -# TODO: Specific bounces for SMTP good citizenship: 200/404/400 -class ActionMailbox::InboundEmailsController < ActionController::Base - skip_forgery_protection - before_action :require_rfc822_message, only: :create - - def create - ActionMailbox::InboundEmail.create_and_extract_message_id!(params[:message]) - head :created - end - - private - def require_rfc822_message - head :unsupported_media_type unless params.require(:message).content_type == 'message/rfc822' - end -end diff --git a/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb new file mode 100644 index 0000000000..d3998be2d4 --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb @@ -0,0 +1,21 @@ +class ActionMailbox::Ingresses::Amazon::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate + + cattr_accessor :verifier + + def self.prepare + self.verifier ||= begin + require "aws-sdk-sns/message_verifier" + Aws::SNS::MessageVerifier.new + end + end + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:content) + end + + private + def authenticate + head :unauthorized unless verifier.authentic?(request.body) + end +end diff --git a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb new file mode 100644 index 0000000000..e878192603 --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -0,0 +1,58 @@ +class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! params.require("body-mime") + end + + private + def authenticate + head :unauthorized unless authenticated? + end + + def authenticated? + if key.present? + Authenticator.new( + key: key, + timestamp: params.require(:timestamp), + token: params.require(:token), + signature: params.require(:signature) + ).authenticated? + else + raise ArgumentError, <<~MESSAGE.squish + Missing required Mailgun API key. Set action_mailbox.mailgun_api_key in your application's + encrypted credentials or provide the MAILGUN_INGRESS_API_KEY environment variable. + MESSAGE + end + end + + def key + Rails.application.credentials.dig(:action_mailbox, :mailgun_api_key) || ENV["MAILGUN_INGRESS_API_KEY"] + end + + class Authenticator + attr_reader :key, :timestamp, :token, :signature + + def initialize(key:, timestamp:, token:, signature:) + @key, @timestamp, @token, @signature = key, Integer(timestamp), token, signature + end + + def authenticated? + signed? && recent? + end + + private + def signed? + ActiveSupport::SecurityUtils.secure_compare signature, expected_signature + end + + # Allow for 2 minutes of drift between Mailgun time and local server time. + def recent? + Time.at(timestamp) >= 2.minutes.ago + end + + def expected_signature + OpenSSL::HMAC.hexdigest OpenSSL::Digest::SHA256.new, key, "#{timestamp}#{token}" + end + end +end diff --git a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb new file mode 100644 index 0000000000..b32b254076 --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -0,0 +1,65 @@ +class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate + + def create + raw_emails.each { |raw_email| ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email } + head :ok + rescue JSON::ParserError => error + logger.error error.message + head :unprocessable_entity + end + + private + def raw_emails + events.select { |event| event["event"] == "inbound" }.collect { |event| event.dig("msg", "raw_msg") } + end + + def events + JSON.parse params.require(:mandrill_events) + end + + + def authenticate + head :unauthorized unless authenticated? + end + + def authenticated? + if key.present? + Authenticator.new(request, key).authenticated? + else + 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 + + def key + Rails.application.credentials.dig(:action_mailbox, :mandrill_api_key) || ENV["MANDRILL_INGRESS_API_KEY"] + end + + class Authenticator + attr_reader :request, :key + + def initialize(request, key) + @request, @key = request, key + end + + def authenticated? + ActiveSupport::SecurityUtils.secure_compare given_signature, expected_signature + end + + private + def given_signature + request.headers["X-Mandrill-Signature"] + end + + def expected_signature + Base64.strict_encode64 OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, key, message) + end + + def message + request.url + request.POST.sort.flatten.join + end + end +end diff --git a/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb new file mode 100644 index 0000000000..133accf651 --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb @@ -0,0 +1,14 @@ +class ActionMailbox::Ingresses::Postfix::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate_by_password, :require_valid_rfc822_message + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read + end + + private + def require_valid_rfc822_message + unless request.content_type == "message/rfc822" + head :unsupported_media_type + end + end +end diff --git a/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb new file mode 100644 index 0000000000..b856eb5b94 --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb @@ -0,0 +1,7 @@ +class ActionMailbox::Ingresses::Sendgrid::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate_by_password + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:email) + end +end diff --git a/app/models/action_mailbox/inbound_email/message_id.rb b/app/models/action_mailbox/inbound_email/message_id.rb index a1ec5c0437..5cfcadaba1 100644 --- a/app/models/action_mailbox/inbound_email/message_id.rb +++ b/app/models/action_mailbox/inbound_email/message_id.rb @@ -6,14 +6,16 @@ module ActionMailbox::InboundEmail::MessageId end module ClassMethods - def create_and_extract_message_id!(raw_email, **options) - create! raw_email: raw_email, message_id: extract_message_id(raw_email), **options + def create_and_extract_message_id!(source, **options) + create! message_id: extract_message_id(source), **options do |inbound_email| + inbound_email.raw_email.attach io: StringIO.new(source), filename: "message.eml", content_type: "message/rfc822" + end end private - def extract_message_id(raw_email) - mail_from_source(raw_email.read).message_id - rescue + def extract_message_id(source) + mail_from_source(source).message_id + rescue => e # FIXME: Add logging with "Couldn't extract Message ID, so will generating a new random ID instead" end end diff --git a/config/routes.rb b/config/routes.rb index 733f137262..99a15d1d32 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,11 +1,20 @@ # frozen_string_literal: true Rails.application.routes.draw do - post "/rails/action_mailbox/inbound_emails" => "action_mailbox/inbound_emails#create", as: :rails_inbound_emails + scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do + post "/amazon/inbound_emails" => "amazon/inbound_emails#create", as: :rails_amazon_inbound_emails + post "/mandrill/inbound_emails" => "mandrill/inbound_emails#create", as: :rails_mandrill_inbound_emails + post "/postfix/inbound_emails" => "postfix/inbound_emails#create", as: :rails_postfix_inbound_emails + post "/sendgrid/inbound_emails" => "sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails + + # Mailgun requires that a webhook's URL end in 'mime' for it to receive the raw contents of emails. + post "/mailgun/inbound_emails/mime" => "mailgun/inbound_emails#create", as: :rails_mailgun_inbound_emails + end # TODO: Should these be mounted within the engine only? scope "rails/conductor/action_mailbox/", module: "rails/conductor/action_mailbox" do - resources :inbound_emails, as: :rails_conductor_inbound_emails - post ":inbound_email_id/reroute" => "reroutes#create", as: :rails_conductor_inbound_email_reroute + resources :inbound_emails, as: :rails_conductor_inbound_emails do + post "reroute" => "reroutes#create" + end end end diff --git a/lib/action_mailbox.rb b/lib/action_mailbox.rb index ae37cb84ed..fbc8122d9d 100644 --- a/lib/action_mailbox.rb +++ b/lib/action_mailbox.rb @@ -6,6 +6,7 @@ module ActionMailbox autoload :Base autoload :Router + mattr_accessor :ingress mattr_accessor :logger mattr_accessor :incinerate_after, default: 30.days end diff --git a/lib/action_mailbox/engine.rb b/lib/action_mailbox/engine.rb index b4758aacb5..cf438d8f24 100644 --- a/lib/action_mailbox/engine.rb +++ b/lib/action_mailbox/engine.rb @@ -14,5 +14,17 @@ module ActionMailbox ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days 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/lib/action_mailbox/test_helper.rb b/lib/action_mailbox/test_helper.rb index a74ea8ef57..23b2bb02ca 100644 --- a/lib/action_mailbox/test_helper.rb +++ b/lib/action_mailbox/test_helper.rb @@ -5,18 +5,15 @@ module ActionMailbox # 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 file_fixture(fixture_name), filename: fixture_name, status: status + create_inbound_email file_fixture(fixture_name).read, status: status end def create_inbound_email_from_mail(status: :processing, **mail_options) - raw_email = Tempfile.new.tap { |io| io.write Mail.new(mail_options).to_s } - create_inbound_email(raw_email, status: status) + create_inbound_email Mail.new(mail_options).to_s, status: status end - def create_inbound_email(io, filename: 'mail.eml', status: :processing) - ActionMailbox::InboundEmail.create_and_extract_message_id! \ - ActionDispatch::Http::UploadedFile.new(tempfile: io, filename: filename, type: 'message/rfc822'), - status: status + def create_inbound_email(source, status: :processing) + ActionMailbox::InboundEmail.create_and_extract_message_id! source, status: status end def receive_inbound_email_from_fixture(*args) diff --git a/lib/tasks/ingress.rake b/lib/tasks/ingress.rake new file mode 100644 index 0000000000..510951aa2a --- /dev/null +++ b/lib/tasks/ingress.rake @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +namespace :action_mailbox do + namespace :ingress do + desc "Pipe an inbound email from STDIN to the Postfix ingress at the given URL" + task :postfix do + require "active_support" + require "active_support/core_ext/object/blank" + require "http" + + unless url = ENV["URL"].presence + abort "5.3.5 URL is required" + end + + unless password = ENV["INGRESS_PASSWORD"].presence + abort "5.3.5 INGRESS_PASSWORD is required" + end + + begin + response = HTTP.basic_auth(user: "actionmailbox", pass: password) + .timeout(connect: 1, write: 10, read: 10) + .post(url, headers: { "Content-Type" => "message/rfc822", "User-Agent" => ENV.fetch("USER_AGENT", "Postfix") }, body: STDIN) + + case + when response.status.success? + puts "2.0.0 HTTP #{response.status}" + when response.status.unauthorized? + abort "4.7.0 HTTP #{response.status}" + when response.status.unsupported_media_type? + abort "5.6.1 HTTP #{response.status}" + else + abort "4.0.0 HTTP #{response.status}" + end + rescue HTTP::ConnectionError => error + abort "4.4.2 Error connecting to the Postfix ingress: #{error.message}" + rescue HTTP::TimeoutError + abort "4.4.7 Timed out piping to the Postfix ingress" + end + end + end +end diff --git a/test/controllers/ingresses/amazon/inbound_emails_controller_test.rb b/test/controllers/ingresses/amazon/inbound_emails_controller_test.rb new file mode 100644 index 0000000000..c36c500cbe --- /dev/null +++ b/test/controllers/ingresses/amazon/inbound_emails_controller_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +ActionMailbox::Ingresses::Amazon::InboundEmailsController.verifier = + Module.new { def self.authentic?(message); true; end } + +class ActionMailbox::Ingresses::Amazon::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :amazon } + + test "receiving an inbound email from Amazon" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_amazon_inbound_emails_url, params: { content: file_fixture("../files/welcome.eml").read }, as: :json + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end +end diff --git a/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb b/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb new file mode 100644 index 0000000000..c5ec71013e --- /dev/null +++ b/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb @@ -0,0 +1,89 @@ +require "test_helper" + +ENV["MAILGUN_INGRESS_API_KEY"] = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL" + +class ActionMailbox::Ingresses::Mailgun::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :mailgun } + + test "receiving an inbound email from Mailgun" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "rejecting a delayed inbound email from Mailgun" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + travel_to "2018-10-09 15:26:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + + assert_response :unauthorized + end + + test "rejecting a forged inbound email from Mailgun" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "Zx8mJBiGmiiyyfWnho3zKyjCg2pxLARoCuBM7X9AKCioShGiMX", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + + assert_response :unauthorized + end + + test "raising when the configured Mailgun API key is nil" do + switch_key_to nil do + assert_raises ArgumentError do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + end + end + + test "raising when the configured Mailgun API key is blank" do + switch_key_to "" do + assert_raises ArgumentError do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + end + end + + private + def switch_key_to(new_key) + previous_key, ENV["MAILGUN_INGRESS_API_KEY"] = ENV["MAILGUN_INGRESS_API_KEY"], new_key + yield + ensure + ENV["MAILGUN_INGRESS_API_KEY"] = previous_key + end +end diff --git a/test/controllers/ingresses/mandrill/inbound_emails_controller_test.rb b/test/controllers/ingresses/mandrill/inbound_emails_controller_test.rb new file mode 100644 index 0000000000..c8a8e731d6 --- /dev/null +++ b/test/controllers/ingresses/mandrill/inbound_emails_controller_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +ENV["MANDRILL_INGRESS_API_KEY"] = "1l9Qf7lutEf7h73VXfBwhw" + +class ActionMailbox::Ingresses::Mandrill::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup do + ActionMailbox.ingress = :mandrill + @events = JSON.generate([{ event: "inbound", msg: { raw_msg: file_fixture("../files/welcome.eml").read } }]) + end + + test "receiving an inbound email from Mandrill" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events } + end + + assert_response :ok + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "rejecting a forged inbound email from Mandrill" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "forged" }, params: { mandrill_events: @events } + end + + assert_response :unauthorized + end + + test "raising when Mandrill API key is nil" do + switch_key_to nil do + assert_raises ArgumentError do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events } + end + end + end + + test "raising when Mandrill API key is blank" do + switch_key_to "" do + assert_raises ArgumentError do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events } + end + end + end + + private + def switch_key_to(new_key) + previous_key, ENV["MANDRILL_INGRESS_API_KEY"] = ENV["MANDRILL_INGRESS_API_KEY"], new_key + yield + ensure + ENV["MANDRILL_INGRESS_API_KEY"] = previous_key + end +end diff --git a/test/controllers/ingresses/postfix/inbound_emails_controller_test.rb b/test/controllers/ingresses/postfix/inbound_emails_controller_test.rb new file mode 100644 index 0000000000..5e0777aa30 --- /dev/null +++ b/test/controllers/ingresses/postfix/inbound_emails_controller_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class ActionMailbox::Ingresses::Postfix::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :postfix } + + test "receiving an inbound email from Postfix" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "rejecting an unauthorized inbound email from Postfix" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_postfix_inbound_emails_url, headers: { "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + + assert_response :unauthorized + end + + test "rejecting an inbound email of an unsupported media type from Postfix" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "text/plain" }, + params: file_fixture("../files/welcome.eml").read + end + + assert_response :unsupported_media_type + end + + test "raising when the configured password is nil" do + switch_password_to nil do + assert_raises ArgumentError do + post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + end + end + + test "raising when the configured password is blank" do + switch_password_to "" do + assert_raises ArgumentError do + post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + end + end +end diff --git a/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb b/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb new file mode 100644 index 0000000000..0c7d0d6846 --- /dev/null +++ b/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class ActionMailbox::Ingresses::Sendgrid::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :sendgrid } + + test "receiving an inbound email from Sendgrid" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "rejecting an unauthorized inbound email from Sendgrid" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_sendgrid_inbound_emails_url, params: { email: file_fixture("../files/welcome.eml").read } + end + + assert_response :unauthorized + end + + test "raising when the configured password is nil" do + switch_password_to nil do + assert_raises ArgumentError do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read } + end + end + end + + test "raising when the configured password is blank" do + switch_password_to "" do + assert_raises ArgumentError do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read } + end + end + end +end diff --git a/test/fixtures/files/text.txt b/test/fixtures/files/text.txt deleted file mode 100644 index 84c3f1cf21..0000000000 --- a/test/fixtures/files/text.txt +++ /dev/null @@ -1 +0,0 @@ -This Is Not An Email! diff --git a/test/test_helper.rb b/test/test_helper.rb index 264f9e8482..b4459f3feb 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,5 +1,5 @@ -# Configure Rails Environment ENV["RAILS_ENV"] = "test" +ENV["RAILS_INBOUND_EMAIL_PASSWORD"] = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL" require_relative "../test/dummy/config/environment" ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] @@ -7,14 +7,11 @@ require "rails/test_help" require "byebug" -# Filter out Minitest backtrace while allowing backtrace from other libraries -# to be shown. Minitest.backtrace_filter = Minitest::BacktraceFilter.new require "rails/test_unit/reporter" Rails::TestUnitReporter.executable = 'bin/test' -# Load fixtures from the engine if ActiveSupport::TestCase.respond_to?(:fixture_path=) ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path @@ -28,6 +25,20 @@ class ActiveSupport::TestCase include ActionMailbox::TestHelper, ActiveJob::TestHelper end +class ActionDispatch::IntegrationTest + private + def credentials + ActionController::HttpAuthentication::Basic.encode_credentials "actionmailbox", ENV["RAILS_INBOUND_EMAIL_PASSWORD"] + end + + def switch_password_to(new_password) + previous_password, ENV["RAILS_INBOUND_EMAIL_PASSWORD"] = ENV["RAILS_INBOUND_EMAIL_PASSWORD"], new_password + yield + ensure + ENV["RAILS_INBOUND_EMAIL_PASSWORD"] = previous_password + end +end + if ARGV.include?("-v") ActiveRecord::Base.logger = Logger.new(STDOUT) ActiveJob::Base.logger = Logger.new(STDOUT) diff --git a/test/unit/controller_test.rb b/test/unit/controller_test.rb deleted file mode 100644 index 508e561244..0000000000 --- a/test/unit/controller_test.rb +++ /dev/null @@ -1,27 +0,0 @@ -require_relative '../test_helper' - -class ActionMailbox::InboundEmailsControllerTest < ActionDispatch::IntegrationTest - test "receiving a valid RFC 822 message" do - assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do - post_inbound_email "welcome.eml" - end - - assert_response :created - - inbound_email = ActionMailbox::InboundEmail.last - assert_equal file_fixture('../files/welcome.eml').read, inbound_email.raw_email.download - end - - test "rejecting a message of an unsupported type" do - assert_no_difference -> { ActionMailbox::InboundEmail.count } do - post rails_inbound_emails_url, params: { message: fixture_file_upload('files/text.txt', 'text/plain') } - end - - assert_response :unsupported_media_type - end - - private - def post_inbound_email(fixture_name) - post rails_inbound_emails_url, params: { message: fixture_file_upload("files/#{fixture_name}", 'message/rfc822') } - end -end diff --git a/test/unit/inbound_email/message_id_test.rb b/test/unit/inbound_email/message_id_test.rb index aa9ce90b4c..c744a5bf99 100644 --- a/test/unit/inbound_email/message_id_test.rb +++ b/test/unit/inbound_email/message_id_test.rb @@ -7,9 +7,7 @@ class ActionMailbox::InboundEmail::MessageIdTest < ActiveSupport::TestCase end test "message id is generated if its missing" do - source_without_message_id = "Date: Fri, 28 Sep 2018 11:08:55 -0700\r\nTo: a@example.com\r\nMime-Version: 1.0\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello!" - inbound_email = create_inbound_email Tempfile.new.tap { |raw_email| raw_email.write source_without_message_id } - + inbound_email = create_inbound_email "Date: Fri, 28 Sep 2018 11:08:55 -0700\r\nTo: a@example.com\r\nMime-Version: 1.0\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello!" assert_not_nil inbound_email.message_id end end