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 --- 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 +++- 7 files changed, 133 insertions(+), 18 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 (limited to 'app') 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 -- cgit v1.2.3 From 3984460424b678d844009319598e2b41c350ca3c Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Wed, 17 Oct 2018 00:12:03 -0400 Subject: Add Mandrill support --- .../mandrill/inbound_emails_controller.rb | 59 ++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb (limited to 'app') 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..825ec6eabd --- /dev/null +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -0,0 +1,59 @@ +class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbox::BaseController + before_action :ensure_authenticated + + def create + raw_emails.each { |raw_email| ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email } + head :ok + rescue JSON::ParserError => error + log.error error.message + head :unprocessable_entity + end + + private + def raw_emails + events.lazy. + select { |event| event["event"] == "inbound" }. + collect { |event| event.dig("msg", "raw_msg") }. + collect { |message| StringIO.new message } + end + + def events + JSON.parse params.require(:mandrill_events) + end + + + def ensure_authenticated + head :unauthorized unless authenticated? + end + + def authenticated? + Authenticator.new(request).authenticated? + end + + class Authenticator + cattr_accessor :key + + attr_reader :request + + def initialize(request) + @request = request + 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.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, key, message)).strip + end + + def message + [ request.original_url, request.POST.sort ].flatten.join + end + end +end -- cgit v1.2.3 From b3919d01554d31c5486d17332f4a4dde89a23239 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Thu, 18 Oct 2018 10:23:17 -0400 Subject: Don't require Postfix to send form data --- .../ingresses/amazon/inbound_emails_controller.rb | 7 +------ .../ingresses/mailgun/inbound_emails_controller.rb | 8 +------- .../ingresses/mandrill/inbound_emails_controller.rb | 3 +-- .../ingresses/postfix/inbound_emails_controller.rb | 11 +++++++++-- .../ingresses/sendgrid/inbound_emails_controller.rb | 7 +------ app/models/action_mailbox/inbound_email/message_id.rb | 15 +++++---------- 6 files changed, 18 insertions(+), 33 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb index 7412928b56..53b9bd07ec 100644 --- a/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb @@ -6,16 +6,11 @@ class ActionMailbox::Ingresses::Amazon::InboundEmailsController < ActionMailbox: cattr_accessor :verifier, default: Aws::SNS::MessageVerifier.new def create - ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email + ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:content) head :no_content end private - def raw_email - StringIO.new params.require(:content) - end - - def ensure_verified head :unauthorized unless verified? 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 index 4d194a3e00..77e05d712a 100644 --- a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -2,16 +2,11 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox before_action :ensure_authenticated def create - ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email + ActionMailbox::InboundEmail.create_and_extract_message_id! params.require("body-mime") head :ok end private - def raw_email - StringIO.new params.require("body-mime") - end - - def ensure_authenticated head :unauthorized unless authenticated? end @@ -26,7 +21,6 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox params.permit(:timestamp, :token, :signature).to_h.symbolize_keys end - class Authenticator cattr_accessor :key diff --git a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb index 825ec6eabd..7910359f51 100644 --- a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -13,8 +13,7 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo def raw_emails events.lazy. select { |event| event["event"] == "inbound" }. - collect { |event| event.dig("msg", "raw_msg") }. - collect { |message| StringIO.new message } + collect { |event| event.dig("msg", "raw_msg") } end def events diff --git a/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb index 2631302606..d34257f9e9 100644 --- a/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb @@ -2,10 +2,17 @@ class ActionMailbox::Ingresses::Postfix::InboundEmailsController < ActionMailbox cattr_accessor :username, default: "actionmailbox" cattr_accessor :password - before_action :authenticate + before_action :authenticate, :require_valid_rfc822_message def create - ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:message) + ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read head :no_content 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 index 0b9e2e1866..19c3b1b2c4 100644 --- a/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb @@ -5,12 +5,7 @@ class ActionMailbox::Ingresses::Sendgrid::InboundEmailsController < ActionMailbo before_action :authenticate def create - ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email + ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(: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 601c5f1a7e..5cfcadaba1 100644 --- a/app/models/action_mailbox/inbound_email/message_id.rb +++ b/app/models/action_mailbox/inbound_email/message_id.rb @@ -6,20 +6,15 @@ module ActionMailbox::InboundEmail::MessageId end module ClassMethods - def create_and_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 + 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 + 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 -- cgit v1.2.3 From 34b809ce6d6a73296bfbc9f9677f7fb04c218c49 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Thu, 18 Oct 2018 16:29:35 -0400 Subject: Fix logger reference --- .../action_mailbox/ingresses/mandrill/inbound_emails_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb index 7910359f51..6ee2f5be54 100644 --- a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -5,7 +5,7 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo raw_emails.each { |raw_email| ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email } head :ok rescue JSON::ParserError => error - log.error error.message + logger.error error.message head :unprocessable_entity end -- cgit v1.2.3 From 4411095290f24ccb2e263c9534acfd19d081120f Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Thu, 18 Oct 2018 16:31:38 -0400 Subject: Inline --- .../action_mailbox/ingresses/mandrill/inbound_emails_controller.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb index 6ee2f5be54..afaf3df28e 100644 --- a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -11,9 +11,7 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo private def raw_emails - events.lazy. - select { |event| event["event"] == "inbound" }. - collect { |event| event.dig("msg", "raw_msg") } + events.select { |event| event["event"] == "inbound" }.collect { |event| event.dig("msg", "raw_msg") } end def events -- cgit v1.2.3 From dbeef18b3ff04ce9ae8a53511f2afd0c7bae99e1 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Fri, 19 Oct 2018 13:11:13 -0400 Subject: Rely on the 204 No Content default response --- .../action_mailbox/ingresses/amazon/inbound_emails_controller.rb | 1 - .../action_mailbox/ingresses/postfix/inbound_emails_controller.rb | 1 - .../action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb | 1 - 3 files changed, 3 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb index 53b9bd07ec..557d1aeb04 100644 --- a/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb @@ -7,7 +7,6 @@ class ActionMailbox::Ingresses::Amazon::InboundEmailsController < ActionMailbox: def create ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:content) - head :no_content end private diff --git a/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb index d34257f9e9..72303378a9 100644 --- a/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb @@ -6,7 +6,6 @@ class ActionMailbox::Ingresses::Postfix::InboundEmailsController < ActionMailbox def create ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read - head :no_content end private diff --git a/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb index 19c3b1b2c4..f31845d8cd 100644 --- a/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb @@ -6,6 +6,5 @@ class ActionMailbox::Ingresses::Sendgrid::InboundEmailsController < ActionMailbo def create ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:email) - head :no_content end end -- cgit v1.2.3 From 8ff493d683d9fbdb508bd94643dbb11c54ac17b7 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Fri, 19 Oct 2018 13:12:03 -0400 Subject: Style --- .../action_mailbox/ingresses/mailgun/inbound_emails_controller.rb | 1 - 1 file changed, 1 deletion(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb index 77e05d712a..2ca970fa8e 100644 --- a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -23,7 +23,6 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox class Authenticator cattr_accessor :key - attr_reader :timestamp, :token, :signature def initialize(timestamp:, token:, signature:) -- cgit v1.2.3 From fbd4219274f7c30de391ec8d7b6b6c5d76fb57c7 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Fri, 19 Oct 2018 15:21:26 -0400 Subject: Don't short-circuit --- app/controllers/action_mailbox/base_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/base_controller.rb b/app/controllers/action_mailbox/base_controller.rb index 680c6a9615..6f0e7e42d1 100644 --- a/app/controllers/action_mailbox/base_controller.rb +++ b/app/controllers/action_mailbox/base_controller.rb @@ -4,7 +4,7 @@ class ActionMailbox::BaseController < ActionController::Base 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_username, username) & ActiveSupport::SecurityUtils.secure_compare(given_password, password) end end -- cgit v1.2.3 From ce93bbae01dc7089cbdca510574e0009b7f669ed Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Fri, 19 Oct 2018 17:34:06 -0400 Subject: Tighten up the acceptable drift --- .../action_mailbox/ingresses/mailgun/inbound_emails_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb index 2ca970fa8e..11c47a5ea9 100644 --- a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -38,9 +38,9 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox ActiveSupport::SecurityUtils.secure_compare signature, expected_signature end - # Allow for 10 minutes of drift between Mailgun time and local server time. + # Allow for 2 minutes of drift between Mailgun time and local server time. def recent? - time >= 10.minutes.ago + time >= 2.minutes.ago end def expected_signature -- cgit v1.2.3 From 96f8ca37fb47062e0cdc9e3a2765dfe58dcb6770 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Fri, 19 Oct 2018 19:57:38 -0400 Subject: Mailgun copes with a 204 response status --- .../action_mailbox/ingresses/mailgun/inbound_emails_controller.rb | 1 - 1 file changed, 1 deletion(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb index 11c47a5ea9..10af57c58f 100644 --- a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -3,7 +3,6 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox def create ActionMailbox::InboundEmail.create_and_extract_message_id! params.require("body-mime") - head :ok end private -- cgit v1.2.3 From c64a58f331331feca169b72ae3998ba748bd8e5a Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Fri, 19 Oct 2018 22:33:45 -0400 Subject: Style --- .../action_mailbox/ingresses/mandrill/inbound_emails_controller.rb | 1 - 1 file changed, 1 deletion(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb index afaf3df28e..19891e7f9c 100644 --- a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -29,7 +29,6 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo class Authenticator cattr_accessor :key - attr_reader :request def initialize(request) -- cgit v1.2.3 From 9182bbd1ebc88699ff101d7a0a304f387b091140 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Fri, 19 Oct 2018 22:56:31 -0400 Subject: Inline --- .../ingresses/mailgun/inbound_emails_controller.rb | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb index 10af57c58f..46b0977592 100644 --- a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -11,13 +11,11 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox end def authenticated? - Authenticator.new(authentication_params).authenticated? - rescue ArgumentError - false - end - - def authentication_params - params.permit(:timestamp, :token, :signature).to_h.symbolize_keys + Authenticator.new( + timestamp: params.require(:timestamp), + token: params.require(:token), + signature: params.require(:signature) + ).authenticated? end class Authenticator @@ -25,7 +23,7 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox attr_reader :timestamp, :token, :signature def initialize(timestamp:, token:, signature:) - @timestamp, @token, @signature = timestamp, token, signature + @timestamp, @token, @signature = Integer(timestamp), token, signature end def authenticated? @@ -39,15 +37,11 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox # Allow for 2 minutes of drift between Mailgun time and local server time. def recent? - time >= 2.minutes.ago + Time.at(timestamp) >= 2.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 -- cgit v1.2.3 From b3641075fc4568b783f59b919fc8a5fb795d09ce Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Fri, 19 Oct 2018 23:07:46 -0400 Subject: Go strict --- .../action_mailbox/ingresses/mandrill/inbound_emails_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb index 19891e7f9c..0b01087c3d 100644 --- a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -45,7 +45,7 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo end def expected_signature - Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, key, message)).strip + Base64.strict_encode64 OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, key, message) end def message -- cgit v1.2.3 From 02fcfec0c682cb3ff175927155a37e934ee1d0fe Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Fri, 19 Oct 2018 23:14:47 -0400 Subject: Skip needless array allocation --- .../action_mailbox/ingresses/mandrill/inbound_emails_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb index 0b01087c3d..31e1315ccd 100644 --- a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -49,7 +49,7 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo end def message - [ request.original_url, request.POST.sort ].flatten.join + request.url + request.POST.sort.flatten.join end end end -- cgit v1.2.3 From be0a8bec8701c7df2667dbf1569429218ea30370 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Mon, 29 Oct 2018 13:45:24 -0400 Subject: Raise when required config is missing --- app/controllers/action_mailbox/base_controller.rb | 10 +++++++--- .../ingresses/mailgun/inbound_emails_controller.rb | 9 +++++++++ .../ingresses/mandrill/inbound_emails_controller.rb | 9 +++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/base_controller.rb b/app/controllers/action_mailbox/base_controller.rb index 6f0e7e42d1..a64a817b51 100644 --- a/app/controllers/action_mailbox/base_controller.rb +++ b/app/controllers/action_mailbox/base_controller.rb @@ -3,9 +3,13 @@ class ActionMailbox::BaseController < ActionController::Base 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) + if username.present? && password.present? + 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 + else + raise ArgumentError, "Missing required ingress credentials" end 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 index 46b0977592..c7e53b07f4 100644 --- a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -24,6 +24,8 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox def initialize(timestamp:, token:, signature:) @timestamp, @token, @signature = Integer(timestamp), token, signature + + ensure_presence_of_key end def authenticated? @@ -31,6 +33,13 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox end private + def ensure_presence_of_key + unless key.present? + raise ArgumentError, "Missing required Mailgun API key" + end + end + + def signed? ActiveSupport::SecurityUtils.secure_compare signature, expected_signature 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 index 31e1315ccd..bcaa5faf23 100644 --- a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -33,6 +33,8 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo def initialize(request) @request = request + + ensure_presence_of_key end def authenticated? @@ -40,6 +42,13 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo end private + def ensure_presence_of_key + unless key.present? + raise ArgumentError, "Missing required Mandrill API key" + end + end + + def given_signature request.headers["X-Mandrill-Signature"] end -- cgit v1.2.3 From cb041ddc7e94da15e2db72188545f78da6cadb53 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Mon, 29 Oct 2018 14:19:46 -0400 Subject: Stage an Action Controller extraction --- app/controllers/action_mailbox/base_controller.rb | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/base_controller.rb b/app/controllers/action_mailbox/base_controller.rb index a64a817b51..d3846b06e1 100644 --- a/app/controllers/action_mailbox/base_controller.rb +++ b/app/controllers/action_mailbox/base_controller.rb @@ -4,12 +4,17 @@ class ActionMailbox::BaseController < ActionController::Base private def authenticate if username.present? && password.present? - 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 + http_basic_authenticate_or_request_with username: username, password: password, realm: "Action Mailbox" else raise ArgumentError, "Missing required ingress credentials" end 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 -- cgit v1.2.3 From 7755f9b381c007ce98e0858473a9f29f1cd25311 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Mon, 5 Nov 2018 09:11:01 -0500 Subject: Read ingress passwords/API keys from encrypted credentials Fall back to ENV for people who prefer that approach. --- app/controllers/action_mailbox/base_controller.rb | 24 ++++++++++++-- .../ingresses/mailgun/inbound_emails_controller.rb | 38 ++++++++++++---------- .../mandrill/inbound_emails_controller.rb | 29 +++++++++-------- .../ingresses/postfix/inbound_emails_controller.rb | 5 +-- .../sendgrid/inbound_emails_controller.rb | 5 +-- 5 files changed, 58 insertions(+), 43 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/base_controller.rb b/app/controllers/action_mailbox/base_controller.rb index d3846b06e1..a2f7eb4b61 100644 --- a/app/controllers/action_mailbox/base_controller.rb +++ b/app/controllers/action_mailbox/base_controller.rb @@ -1,15 +1,33 @@ class ActionMailbox::BaseController < ActionController::Base skip_forgery_protection + before_action :ensure_configured + private - def authenticate - if username.present? && password.present? - http_basic_authenticate_or_request_with username: username, password: password, realm: "Action Mailbox" + 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| diff --git a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb index c7e53b07f4..0b763dcf18 100644 --- a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -11,21 +11,30 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox end def authenticated? - Authenticator.new( - timestamp: params.require(:timestamp), - token: params.require(:token), - signature: params.require(:signature) - ).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 - class Authenticator - cattr_accessor :key - attr_reader :timestamp, :token, :signature + def key + Rails.application.credentials.dig(:action_mailbox, :mailgun_api_key) || ENV["MAILGUN_INGRESS_API_KEY"] + end - def initialize(timestamp:, token:, signature:) - @timestamp, @token, @signature = Integer(timestamp), token, signature + class Authenticator + attr_reader :key, :timestamp, :token, :signature - ensure_presence_of_key + def initialize(key:, timestamp:, token:, signature:) + @key, @timestamp, @token, @signature = key, Integer(timestamp), token, signature end def authenticated? @@ -33,13 +42,6 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox end private - def ensure_presence_of_key - unless key.present? - raise ArgumentError, "Missing required Mailgun API key" - end - end - - def signed? ActiveSupport::SecurityUtils.secure_compare signature, expected_signature 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 index bcaa5faf23..0601125cdb 100644 --- a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -24,17 +24,25 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo end def authenticated? - Authenticator.new(request).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 - class Authenticator - cattr_accessor :key - attr_reader :request + def key + Rails.application.credentials.dig(:action_mailbox, :mandrill_api_key) || ENV["MANDRILL_INGRESS_API_KEY"] + end - def initialize(request) - @request = request + class Authenticator + attr_reader :request, :key - ensure_presence_of_key + def initialize(request, key) + @request, @key = request, key end def authenticated? @@ -42,13 +50,6 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo end private - def ensure_presence_of_key - unless key.present? - raise ArgumentError, "Missing required Mandrill API key" - end - end - - def given_signature request.headers["X-Mandrill-Signature"] 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 index 72303378a9..133accf651 100644 --- a/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/postfix/inbound_emails_controller.rb @@ -1,8 +1,5 @@ class ActionMailbox::Ingresses::Postfix::InboundEmailsController < ActionMailbox::BaseController - cattr_accessor :username, default: "actionmailbox" - cattr_accessor :password - - before_action :authenticate, :require_valid_rfc822_message + before_action :authenticate_by_password, :require_valid_rfc822_message def create ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read diff --git a/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb index f31845d8cd..b856eb5b94 100644 --- a/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb @@ -1,8 +1,5 @@ class ActionMailbox::Ingresses::Sendgrid::InboundEmailsController < ActionMailbox::BaseController - cattr_accessor :username, default: "actionmailbox" - cattr_accessor :password - - before_action :authenticate + before_action :authenticate_by_password def create ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:email) -- cgit v1.2.3 From d02ae4c7ae336412a5ff19400cda6b87ed58a465 Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Mon, 5 Nov 2018 09:56:02 -0500 Subject: Rename authentication callbacks --- .../ingresses/amazon/inbound_emails_controller.rb | 10 +++------- .../ingresses/mailgun/inbound_emails_controller.rb | 4 ++-- .../ingresses/mandrill/inbound_emails_controller.rb | 4 ++-- 3 files changed, 7 insertions(+), 11 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb index 557d1aeb04..4d56e27c76 100644 --- a/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb @@ -1,5 +1,5 @@ class ActionMailbox::Ingresses::Amazon::InboundEmailsController < ActionMailbox::BaseController - before_action :ensure_verified + before_action :authenticate # TODO: Lazy-load the AWS SDK require "aws-sdk-sns/message_verifier" @@ -10,11 +10,7 @@ class ActionMailbox::Ingresses::Amazon::InboundEmailsController < ActionMailbox: end private - def ensure_verified - head :unauthorized unless verified? - end - - def verified? - verifier.authentic?(request.body) + 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 index 0b763dcf18..e878192603 100644 --- a/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -1,12 +1,12 @@ class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox::BaseController - before_action :ensure_authenticated + before_action :authenticate def create ActionMailbox::InboundEmail.create_and_extract_message_id! params.require("body-mime") end private - def ensure_authenticated + def authenticate head :unauthorized unless authenticated? 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 index 0601125cdb..b32b254076 100644 --- a/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -1,5 +1,5 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbox::BaseController - before_action :ensure_authenticated + before_action :authenticate def create raw_emails.each { |raw_email| ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email } @@ -19,7 +19,7 @@ class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbo end - def ensure_authenticated + def authenticate head :unauthorized unless authenticated? end -- cgit v1.2.3 From b859eebff8545eea972d479137e14b917e6519dc Mon Sep 17 00:00:00 2001 From: George Claghorn Date: Mon, 5 Nov 2018 12:39:37 -0500 Subject: Only load the AWS SDK when the Amazon ingress is configured --- app/controllers/action_mailbox/base_controller.rb | 4 ++++ .../ingresses/amazon/inbound_emails_controller.rb | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) (limited to 'app') diff --git a/app/controllers/action_mailbox/base_controller.rb b/app/controllers/action_mailbox/base_controller.rb index a2f7eb4b61..c234ecd250 100644 --- a/app/controllers/action_mailbox/base_controller.rb +++ b/app/controllers/action_mailbox/base_controller.rb @@ -1,6 +1,10 @@ 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 diff --git a/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb index 4d56e27c76..d3998be2d4 100644 --- a/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb +++ b/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb @@ -1,9 +1,14 @@ class ActionMailbox::Ingresses::Amazon::InboundEmailsController < ActionMailbox::BaseController before_action :authenticate - # TODO: Lazy-load the AWS SDK - require "aws-sdk-sns/message_verifier" - cattr_accessor :verifier, default: Aws::SNS::MessageVerifier.new + 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) -- cgit v1.2.3