aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGeorge Claghorn <george@basecamp.com>2018-10-06 22:02:08 -0400
committerGeorge Claghorn <george@basecamp.com>2018-10-11 12:51:13 -0400
commit6b7eac5c51cbef4acd1ab7f48884e7b614f71678 (patch)
tree8eb6b0ebee4ad6073f6cb48b3effc60d4262d2f2
parent47445511862a4c9979fb46889011edf585391091 (diff)
downloadrails-6b7eac5c51cbef4acd1ab7f48884e7b614f71678.tar.gz
rails-6b7eac5c51cbef4acd1ab7f48884e7b614f71678.tar.bz2
rails-6b7eac5c51cbef4acd1ab7f48884e7b614f71678.zip
Accept inbound emails from a variety of ingresses
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock13
-rw-r--r--app/controllers/action_mailbox/base_controller.rb11
-rw-r--r--app/controllers/action_mailbox/inbound_emails_controller.rb17
-rw-r--r--app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb26
-rw-r--r--app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb61
-rw-r--r--app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb11
-rw-r--r--app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb16
-rw-r--r--app/models/action_mailbox/inbound_email/message_id.rb9
-rw-r--r--config/routes.rb9
-rw-r--r--test/controllers/ingresses/amazon/inbound_emails_controller_test.rb18
-rw-r--r--test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb51
-rw-r--r--test/controllers/ingresses/postfix/inbound_emails_controller_test.rb33
-rw-r--r--test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb33
-rw-r--r--test/unit/controller_test.rb27
15 files changed, 291 insertions, 46 deletions
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