Skip to content

Commit

Permalink
Merge pull request #15 from ssunday/update-and-s3-download
Browse files Browse the repository at this point in the history
Bring up to date, add s3 download flow from Rails PR, Github Actions
  • Loading branch information
bobf authored May 16, 2024
2 parents e598878 + 4cfa357 commit 5793cd4
Show file tree
Hide file tree
Showing 23 changed files with 407 additions and 206 deletions.
18 changes: 18 additions & 0 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: 'CI'
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Ruby and gems
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run tests
run: make
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Metrics/BlockLength:

AllCops:
NewCops: enable
SuggestExtensions: false
Exclude:
- 'bin/**/*'
- 'db/migrate/**/*.rb'
Expand All @@ -32,3 +33,6 @@ Style/HashTransformKeys:
Enabled: true
Style/HashTransformValues:
Enabled: true

Style/HashSyntax:
Enabled: false
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.5.3
3.2.3
9 changes: 9 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@
source 'https://rubygems.org'
git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
gemspec

gem 'devpack', '~> 0.3.3'
gem 'rake', '~> 13.0'
gem 'rspec-its', '~> 1.3'
gem 'rspec-rails', '~> 6.1'
gem 'rubocop', '~> 1.63'
gem 'sqlite3', '~> 1.4'
gem 'strong_versions', '~> 0.4.5'
gem 'webmock', '~> 3.8'
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,20 @@ gem 'action_mailbox_amazon_ingress', '~> 0.1.3'

### Amazon SES/SNS

Configure _SES_ to [route emails through SNS](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/configure-sns-notifications.html).
1. [Configure SES](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-notifications.html) to (save emails to S3)(https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-s3.html) or to send them as raw messages.

If your website is hosted at https://www.example.com then configure _SNS_ to publish the _SES_ notification topic to this _HTTP_ endpoint:

https://example.com/rails/action_mailbox/amazon/inbound_emails
2. [Configure the SNS topic for SES or for the S3 action](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-action-sns.html) to send notifications to +/rails/action_mailbox/amazon/inbound_emails+. For example, if your website is hosted at https://www.example.com then configure _SNS_ to publish the _SES_ notification topic to this _HTTP_ endpoint: https://example.com/rails/action_mailbox/amazon/inbound_emails

### Rails

Configure _ActionMailbox_ to accept emails from Amazon SES:
1. Configure _ActionMailbox_ to accept emails from Amazon SES:

```
# config/environments/production.rb
config.action_mailbox.ingress = :amazon
```

Configure which _SNS_ topics will be accepted:
2. Configure which _SNS_ topics will be accepted:

```
# config/environments/production.rb
Expand All @@ -39,7 +37,7 @@ config.action_mailbox.amazon.subscribed_topics = %w(
)
```

Subscriptions will now be auto-confirmed and messages will be delivered via _ActionMailbox_.
SNS Subscriptions will now be auto-confirmed and messages will be automatically handled via _ActionMailbox_.

Note that even if you manually confirm subscriptions you will still need to provide a list of subscribed topics; messages from unrecognized topics will be ignored.

Expand Down Expand Up @@ -99,11 +97,20 @@ You may also pass the following keyword arguments to both helpers:

## Development

### Setup

`bin/setup`

### Testing

Ensure _Rubocop_, _RSpec_, and _StrongVersions_ compliance by running `make`:

```
make
```
### Updating AWS Fixtures

`bundle exec rake sign_aws_fixtures`

## Contributing

Expand Down
69 changes: 69 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,72 @@ require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)

task default: :spec

PRIVATE_KEY = 'spec/fixtures/pem/privatekey.pem'
CERTIFICATE = 'spec/fixtures/pem/certificate.pem'
AWS_FIXTURES = FileList['spec/fixtures/json/*.json'].exclude('**/*/invalid_signature.json')
SIGNABLE_KEYS = %w[
Message
MessageId
Subject
SubscribeURL
Timestamp
Token
TopicArn
Type
].freeze

file PRIVATE_KEY do |t|
require 'openssl'
key = OpenSSL::PKey::RSA.new 2048
File.write(t.name, key.to_pem)
end

file CERTIFICATE => PRIVATE_KEY do |t|
require 'openssl'
key = OpenSSL::PKey::RSA.new File.read(PRIVATE_KEY)
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 2
cert.subject = OpenSSL::X509::Name.parse '/DC=org/DC=ruby-lang/CN=Ruby certificate'
cert.issuer = cert.subject # root CA is the issuer
cert.public_key = key.public_key
cert.not_before = Time.now
cert.not_after = cert.not_before + (1 * 365 * 24 * 60 * 60) # 10 years validity
ef = OpenSSL::X509::ExtensionFactory.new
ef.subject_certificate = cert
ef.issuer_certificate = cert
cert.add_extension(ef.create_extension('keyUsage', 'digitalSignature', true))
cert.add_extension(ef.create_extension('subjectKeyIdentifier', 'hash', false))
cert.sign(key, OpenSSL::Digest.new('SHA256'))

File.write(t.name, cert.to_pem)
end
task certificates: [PRIVATE_KEY, CERTIFICATE]

desc 'Sign AWS SES fixtures, must be called if fixtures are modified.'
task sign_aws_fixtures: :certificates do
require 'openssl'
require 'json'
require 'base64'

key = OpenSSL::PKey::RSA.new File.read(PRIVATE_KEY)

AWS_FIXTURES.each do |fixture|
data = JSON.parse File.read(fixture)
string = canonical_string(data)
signed_string = key.sign('SHA1', string)
data['Signature'] = Base64.encode64(signed_string)
File.write(fixture, JSON.pretty_generate(data))
end
end

def canonical_string(message)
parts = []

SIGNABLE_KEYS.each do |key|
value = message[key]
parts << "#{key}\n#{value}\n" unless value.nil? || value.empty?
end
parts.join
end
18 changes: 5 additions & 13 deletions action_mailbox_amazon_ingress.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,21 @@ Gem::Specification.new do |spec|
spec.version = ActionMailboxAmazonIngress::VERSION
spec.authors = ['Bob Farrell']
spec.email = ['[email protected]']
spec.required_ruby_version = '>= 2.5'
spec.required_ruby_version = '>= 3.2'

spec.summary = 'Amazon SES ingress for Rails ActionMailbox'
spec.description = 'Integrate Amazon SES with ActionMailbox'
spec.homepage = 'https://github.com/bobf/action_mailbox_amazon_ingress'
spec.license = 'MIT'

spec.metadata = { 'rubygems_mfa_required' => 'true' }
spec.files = Dir.chdir(File.expand_path(__dir__)) do
`git ls-files -z`.split("\x0").reject do |f|
f.match(%r{^(test|spec|features)/})
end
end
spec.require_paths = ['lib']

spec.add_runtime_dependency 'aws-sdk-sns', '~> 1.23'
spec.add_dependency 'actionmailbox', '>= 6.0'

spec.add_development_dependency 'devpack', '~> 0.3.3'
spec.add_development_dependency 'rake', '~> 13.0'
spec.add_development_dependency 'rspec-its', '~> 1.3'
spec.add_development_dependency 'rspec-rails', '~> 4.0'
spec.add_development_dependency 'rubocop', '~> 0.90.0'
spec.add_development_dependency 'sqlite3', '~> 1.4'
spec.add_development_dependency 'strong_versions', '~> 0.4.5'
spec.add_development_dependency 'webmock', '~> 3.8'
spec.add_runtime_dependency 'aws-sdk-s3', '~> 1.151'
spec.add_runtime_dependency 'aws-sdk-sns', '~> 1.75'
spec.add_dependency 'actionmailbox', '~> 7.1'
end
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'action_mailbox_amazon_ingress/sns_notification'

module ActionMailbox
# Ingests inbound emails from Amazon SES/SNS and confirms subscriptions.
#
Expand All @@ -26,112 +28,47 @@ module ActionMailbox
# - <tt>401 Unauthorized</tt> if a request does not contain a valid signature
# - <tt>404 Not Found</tt> if the Amazon ingress has not been configured
# - <tt>422 Unprocessable Entity</tt> if a request provides invalid parameters
#
# == Usage
#
# 1. Tell Action Mailbox to accept emails from Amazon SES:
#
# # config/environments/production.rb
# config.action_mailbox.ingress = :amazon
#
# 2. Configure which SNS topics will be accepted:
#
# config.action_mailbox.amazon.subscribed_topics = %w(
# arn:aws:sns:eu-west-1:123456789001:example-topic-1
# arn:aws:sns:us-east-1:123456789002:example-topic-2
# )
#
# 3. {Configure SES}[https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-notifications.html]
# to route emails through SNS.
#
# Configure SNS to send emails to +/rails/action_mailbox/amazon/inbound_emails+.
#
# If your application is found at <tt>https://example.com</tt> you would
# specify the fully-qualified URL <tt>https://example.com/rails/action_mailbox/amazon/inbound_emails</tt>.
#

module Ingresses
module Amazon
class InboundEmailsController < ActionMailbox::BaseController
before_action :verify_authenticity
before_action :validate_topic
before_action :confirm_subscription
before_action :verify_authenticity, :validate_topic, :confirm_subscription

def create
head :bad_request unless mail.present?
head :bad_request unless notification.message_content.present?

ActionMailbox::InboundEmail.create_and_extract_message_id!(mail)
ActionMailbox::InboundEmail.create_and_extract_message_id!(notification.message_content)
head :no_content
end

private

def verify_authenticity
head :bad_request unless notification.present?
head :unauthorized unless verified?
head :unauthorized unless notification.verified?
end

def confirm_subscription
return unless notification['Type'] == 'SubscriptionConfirmation'
return head :ok if confirmation_response_code&.start_with?('2')
return unless notification.type == 'SubscriptionConfirmation'
return head :ok if notification.subscription_confirmed?

Rails.logger.error('SNS subscription confirmation request rejected.')
head :unprocessable_entity
end

def validate_topic
return if valid_topics.include?(topic)
return if valid_topics.include?(notification.topic)

Rails.logger.warn("Ignoring unknown topic: #{topic}")
head :unauthorized
end

def confirmation_response_code
@confirmation_response_code ||= begin
Net::HTTP.get_response(URI(notification['SubscribeURL'])).code
end
end

def notification
@notification ||= JSON.parse(request.body.read)
rescue JSON::ParserError => e
Rails.logger.warn("Unable to parse SNS notification: #{e}")
nil
end

def verified?
verifier.authentic?(@notification.to_json)
end

def verifier
Aws::SNS::MessageVerifier.new
end

def message
@message ||= JSON.parse(notification['Message'])
end

def mail
return nil unless notification['Type'] == 'Notification'
return nil unless message['notificationType'] == 'Received'

message_content
end

def message_content
return message['content'] unless destination

"X-Original-To: #{destination}\n#{message['content']}"
end

def destination
message.dig('mail', 'destination')&.first
@notification ||= ActionMailboxAmazonIngress::SnsNotification.new(request.raw_post)
end

def topic
return nil unless notification.present?

notification['TopicArn']
@topic ||= notification.topic
end

def valid_topics
Expand Down
14 changes: 10 additions & 4 deletions lib/action_mailbox_amazon_ingress/rspec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,21 @@ def amazon_ingress_deliver_subscription_confirmation(options = {})
subscription_confirmation = SubscriptionConfirmation.new(**options)
stub_aws_sns_message_verifier(subscription_confirmation)
stub_aws_sns_subscription_request
post subscription_confirmation.url, params: subscription_confirmation.params.to_json,
headers: subscription_confirmation.headers

post subscription_confirmation.url,
params: subscription_confirmation.params,
headers: subscription_confirmation.headers,
as: :json
end

def amazon_ingress_deliver_email(options = {})
email = Email.new(**options)
stub_aws_sns_message_verifier(email)
post email.url, params: email.params.to_json,
headers: email.headers

post email.url,
params: email.params,
headers: email.headers,
as: :json
end

private
Expand Down
Loading

0 comments on commit 5793cd4

Please sign in to comment.