From 6b7eac5c51cbef4acd1ab7f48884e7b614f71678 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Sat, 6 Oct 2018 22:02:08 -0400 Subject: Accept inbound emails from a variety of ingresses --- Gemfile | 2 + Gemfile.lock | 13 +++++ app/controllers/action_mailbox/base_controller.rb | 11 ++++ .../action_mailbox/inbound_emails_controller.rb | 17 ------ .../ingresses/amazon/inbound_emails_controller.rb | 26 +++++++++ .../ingresses/mailgun/inbound_emails_controller.rb | 61 ++++++++++++++++++++++ .../ingresses/postfix/inbound_emails_controller.rb | 11 ++++ .../sendgrid/inbound_emails_controller.rb | 16 ++++++ .../action_mailbox/inbound_email/message_id.rb | 9 +++- config/routes.rb | 9 +++- .../amazon/inbound_emails_controller_test.rb | 18 +++++++ .../mailgun/inbound_emails_controller_test.rb | 51 ++++++++++++++++++ .../postfix/inbound_emails_controller_test.rb | 33 ++++++++++++ .../sendgrid/inbound_emails_controller_test.rb | 33 ++++++++++++ test/unit/controller_test.rb | 27 ---------- 15 files changed, 291 insertions(+), 46 deletions(-) create mode 100644 app/controllers/action_mailbox/base_controller.rb delete mode 100644 app/controllers/action_mailbox/inbound_emails_controller.rb create mode 100644 app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb create mode 100644 app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb create mode 100644 app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb create mode 100644 app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb create mode 100644 test/controllers/ingresses/amazon/inbound_emails_controller_test.rb create mode 100644 test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb create mode 100644 test/controllers/ingresses/postfix/inbound_emails_controller_test.rb create mode 100644 test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb delete mode 100644 test/unit/controller_test.rb 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 868d7f0819..96ed132ea1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -71,6 +71,17 @@ PATH GEM remote: https://rubygems.org/ specs: + 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) @@ -80,6 +91,7 @@ GEM activesupport (>= 4.2.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) @@ -125,6 +137,7 @@ PLATFORMS DEPENDENCIES actionmailbox! + aws-sdk-sns bundler (~> 1.15) byebug rails! diff --git a/app/controllers/action_mailbox/base_controller.rb b/app/controllers/action_mailbox/base_controller.rb new file mode 100644 index 0000000000..680c6a9615 --- /dev/null +++ b/app/controllers/action_mailbox/base_controller.rb @@ -0,0 +1,11 @@ +class ActionMailbox::BaseController < ActionController::Base + skip_forgery_protection + + private + def authenticate + authenticate_or_request_with_http_basic("Action Mailbox") 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..7412928b56 --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb @@ -0,0 +1,26 @@ +class ActionMailbox::Ingresses::Amazon::InboundEmailsController < ActionMailbox::BaseController + before_action :ensure_verified + + # TODO: Lazy-load the AWS SDK + require "aws-sdk-sns/message_verifier" + cattr_accessor :verifier, default: Aws::SNS::MessageVerifier.new + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email + head :no_content + end + + private + def raw_email + StringIO.new params.require(:content) + end + + + def ensure_verified + head :unauthorized unless verified? + end + + def verified? + 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..4d194a3e00 --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -0,0 +1,61 @@ +class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox::BaseController + before_action :ensure_authenticated + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email + head :ok + end + + private + def raw_email + StringIO.new params.require("body-mime") + end + + + def ensure_authenticated + head :unauthorized unless authenticated? + end + + def authenticated? + Authenticator.new(authentication_params).authenticated? + rescue ArgumentError + false + end + + def authentication_params + params.permit(:timestamp, :token, :signature).to_h.symbolize_keys + end + + + class Authenticator + cattr_accessor :key + + attr_reader :timestamp, :token, :signature + + def initialize(timestamp:, token:, signature:) + @timestamp, @token, @signature = timestamp, token, signature + end + + def authenticated? + signed? && recent? + end + + private + def signed? + ActiveSupport::SecurityUtils.secure_compare signature, expected_signature + end + + # Allow for 10 minutes of drift between Mailgun time and local server time. + def recent? + time >= 10.minutes.ago + end + + def expected_signature + OpenSSL::HMAC.hexdigest OpenSSL::Digest::SHA256.new, key, "#{timestamp}#{token}" + end + + def time + Time.at Integer(timestamp) + 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..2631302606 --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb @@ -0,0 +1,11 @@ +class ActionMailbox::Ingresses::Postfix::InboundEmailsController < ActionMailbox::BaseController + cattr_accessor :username, default: "actionmailbox" + cattr_accessor :password + + before_action :authenticate + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:message) + head :no_content + 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..0b9e2e1866 --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb @@ -0,0 +1,16 @@ +class ActionMailbox::Ingresses::Sendgrid::InboundEmailsController < ActionMailbox::BaseController + cattr_accessor :username, default: "actionmailbox" + cattr_accessor :password + + before_action :authenticate + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email + head :no_content + end + + private + def raw_email + StringIO.new 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 590dbfc4d7..601c5f1a7e 100644 --- a/app/models/action_mailbox/inbound_email/message_id.rb +++ b/app/models/action_mailbox/inbound_email/message_id.rb @@ -7,7 +7,14 @@ module ActionMailbox::InboundEmail::MessageId module ClassMethods def create_and_extract_message_id!(raw_email, **options) - create! raw_email: raw_email, message_id: extract_message_id(raw_email), **options + create! message_id: extract_message_id(raw_email), **options do |inbound_email| + case raw_email + when ActionDispatch::Http::UploadedFile + inbound_email.raw_email.attach raw_email + else + inbound_email.raw_email.attach io: raw_email.tap(&:rewind), filename: "message.eml", content_type: "message/rfc822" + end + end end private diff --git a/config/routes.rb b/config/routes.rb index 733f137262..dea6cbd659 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,14 @@ # 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" do + post "/amazon/inbound_emails" => "action_mailbox/ingresses/amazon/inbound_emails#create", as: :rails_amazon_inbound_emails + post "/postfix/inbound_emails" => "action_mailbox/ingresses/postfix/inbound_emails#create", as: :rails_postfix_inbound_emails + post "/sendgrid/inbound_emails" => "action_mailbox/ingresses/sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails + + # Mailgun requires that the webhook's URL end in 'mime' for it to receive the raw contents of emails. + post "/mailgun/inbound_emails/mime" => "action_mailbox/ingresses/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 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..5eda6d8d65 --- /dev/null +++ b/test/controllers/ingresses/amazon/inbound_emails_controller_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +ActionMailbox::Ingresses::Amazon::InboundEmailsController.verifier = + Module.new { def self.authentic?(message); true; end } + +class ActionMailbox::Ingresses::Amazon::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + 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..873d0e81d8 --- /dev/null +++ b/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +ActionMailbox::Ingresses::Mailgun::InboundEmailsController::Authenticator.key = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL" + +class ActionMailbox::Ingresses::Mailgun::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + 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 :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 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 +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..bade5215d6 --- /dev/null +++ b/test/controllers/ingresses/postfix/inbound_emails_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +ActionMailbox::Ingresses::Postfix::InboundEmailsController.password = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL" + +class ActionMailbox::Ingresses::Postfix::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + test "receiving an inbound email from Postfix" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_postfix_inbound_emails_url, headers: { authorization: credentials }, + params: { message: fixture_file_upload("files/welcome.eml", "message/rfc822") } + 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, params: { message: fixture_file_upload("files/welcome.eml", "message/rfc822") } + end + + assert_response :unauthorized + end + + private + delegate :username, :password, to: ActionMailbox::Ingresses::Postfix::InboundEmailsController + + def credentials + ActionController::HttpAuthentication::Basic.encode_credentials username, password + 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..7663c6657e --- /dev/null +++ b/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +ActionMailbox::Ingresses::Sendgrid::InboundEmailsController.password = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL" + +class ActionMailbox::Ingresses::Sendgrid::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + 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 + + private + delegate :username, :password, to: ActionMailbox::Ingresses::Sendgrid::InboundEmailsController + + def credentials + ActionController::HttpAuthentication::Basic.encode_credentials username, password + end +end 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 -- cgit v1.2.3