1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Import Action Mailbox

This commit is contained in:
George Claghorn 2018-12-24 15:16:22 -05:00
commit a5b2fff64c
162 changed files with 10906 additions and 177 deletions

View file

@ -8,6 +8,7 @@ AllCops:
- '**/vendor/**/*'
- 'actionpack/lib/action_dispatch/journey/parser.rb'
- 'railties/test/fixtures/tmp/**/*'
- 'actionmailbox/test/dummy/**/*'
- 'node_modules/**/*'
Performance:
@ -133,6 +134,7 @@ Style/FrozenStringLiteralComment:
- 'actionpack/test/**/*.builder'
- 'actionpack/test/**/*.ruby'
- 'activestorage/db/migrate/**/*.rb'
- 'actionmailbox/db/migrate/**/*.rb'
Style/RedundantFreeze:
Enabled: true

View file

@ -37,10 +37,10 @@ before_install:
- "travis_retry gem update --system"
- "travis_retry gem install bundler -v '2.0.0.pre.2'"
- "[[ -z $encrypted_0fb9444d0374_key && -z $encrypted_0fb9444d0374_iv ]] || openssl aes-256-cbc -K $encrypted_0fb9444d0374_key -iv $encrypted_0fb9444d0374_iv -in activestorage/test/service/configurations.yml.enc -out activestorage/test/service/configurations.yml -d"
- "[[ $GEM != 'ac:integration' ]] || yarn install"
- "[[ $GEM != 'av:ujs' ]] || nvm install node"
- "[[ $GEM != 'av:ujs' ]] || node --version"
- "[[ $GEM != 'av:ujs' ]] || (cd actionview && npm install)"
- "[[ $GEM != 'actioncable:integration' ]] || yarn install"
- "[[ $GEM != 'actionview:ujs' ]] || nvm install node"
- "[[ $GEM != 'actionview:ujs' ]] || node --version"
- "[[ $GEM != 'actionview:ujs' ]] || (cd actionview && npm install)"
- "[[ $GEM != 'railties' ]] || (curl -o- -L https://yarnpkg.com/install.sh | bash)"
- "[[ $GEM != 'railties' ]] || export PATH=$HOME/.yarn/bin:$PATH"
@ -56,12 +56,12 @@ env:
global:
- "JRUBY_OPTS='--dev -J-Xmx1024M'"
matrix:
- "GEM=ap,ac"
- "GEM=am,amo,as,av,aj,ast"
- "GEM=as PRESERVE_TIMEZONES=1"
- "GEM=ar:sqlite3"
- "GEM=actionpack,actioncable"
- "GEM=actionmailer,activemodel,activesupport,actionview,activejob,activestorage,actionmailbox"
- "GEM=activesupport PRESERVE_TIMEZONES=1"
- "GEM=activerecord:sqlite3"
- "GEM=guides"
- "GEM=ac:integration"
- "GEM=actioncable:integration"
rvm:
- 2.5.3
@ -88,10 +88,10 @@ matrix:
- "sudo sed -i 's/port = 5433/port = 5432/' /etc/postgresql/10/main/postgresql.conf"
- "sudo service postgresql restart 10"
- rvm: 2.5.3
env: "GEM=av:ujs"
env: "GEM=actionview:ujs"
- rvm: 2.5.3
sudo: required
env: "GEM=aj:integration"
env: "GEM=activejob:integration"
services:
- memcached
- redis-server
@ -104,7 +104,7 @@ matrix:
- "pushd /tmp/beanstalkd-1.10 && make && (./beanstalkd &); popd"
- rvm: ruby-head
sudo: required
env: "GEM=aj:integration"
env: "GEM=activejob:integration"
services:
- memcached
- redis-server
@ -116,14 +116,14 @@ matrix:
- "[ -f /tmp/beanstalkd-1.10/Makefile ] || (curl -L https://github.com/beanstalkd/beanstalkd/archive/v1.10.tar.gz | tar xz -C /tmp)"
- "pushd /tmp/beanstalkd-1.10 && make && (./beanstalkd &); popd"
- rvm: 2.5.3
env: "GEM=ar:mysql2"
env: "GEM=activerecord:mysql2"
sudo: required
before_install:
- "sudo mysql -e \"use mysql; update user set authentication_string='' where User='root'; update user set plugin='mysql_native_password';FLUSH PRIVILEGES;\""
- "sudo mysql_upgrade"
- "sudo service mysql restart"
- rvm: ruby-head
env: "GEM=ar:mysql2"
env: "GEM=activerecord:mysql2"
sudo: required
before_install:
- "sudo mysql -e \"use mysql; update user set authentication_string='' where User='root'; update user set plugin='mysql_native_password';FLUSH PRIVILEGES;\""
@ -131,20 +131,20 @@ matrix:
- "sudo service mysql restart"
- rvm: 2.5.3
env:
- "GEM=ar:mysql2 MYSQL=mariadb"
- "GEM=activerecord:mysql2 MYSQL=mariadb"
addons:
mariadb: 10.3
- rvm: 2.5.3
env:
- "GEM=ar:sqlite3_mem"
- "GEM=activerecord:sqlite3_mem"
- rvm: 2.5.3
env: "GEM=ar:postgresql"
env: "GEM=activerecord:postgresql"
sudo: required
before_install:
- "sudo sed -i 's/port = 5433/port = 5432/' /etc/postgresql/10/main/postgresql.conf"
- "sudo service postgresql restart 10"
- rvm: ruby-head
env: "GEM=ar:postgresql"
env: "GEM=activerecord:postgresql"
sudo: required
before_install:
- "sudo sed -i 's/port = 5433/port = 5432/' /etc/postgresql/10/main/postgresql.conf"
@ -152,15 +152,15 @@ matrix:
- rvm: jruby-head
jdk: oraclejdk8
env:
- "GEM=ap"
- "GEM=actionpack"
- rvm: jruby-head
jdk: oraclejdk8
env:
- "GEM=am,amo,aj"
- "GEM=actionmailer,activemodel,activejob"
allow_failures:
- rvm: ruby-head
- rvm: jruby-head
- env: "GEM=ac:integration"
- env: "GEM=actioncable:integration"
fast_finish: true
notifications:

View file

@ -39,7 +39,7 @@ group :doc do
gem "kindlerb", "~> 1.2.0"
end
# Active Support.
# Active Support
gem "dalli"
gem "listen", ">= 3.0.5", "< 3.2", require: false
gem "libxml-ruby", platforms: :ruby
@ -48,7 +48,7 @@ gem "connection_pool", require: false
# for railties app_generator_test
gem "bootsnap", ">= 1.1.0", require: false
# Active Job.
# Active Job
group :job do
gem "resque", require: false
gem "resque-scheduler", require: false
@ -88,6 +88,10 @@ group :storage do
gem "image_processing", "~> 1.2"
end
# Action Mailbox
gem "aws-sdk-sns", require: false
gem "webmock"
group :ujs do
gem "qunit-selenium"
gem "chromedriver-helper"

View file

@ -39,6 +39,13 @@ PATH
actionpack (= 6.0.0.alpha)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.0.0.alpha)
actionpack (= 6.0.0.alpha)
activejob (= 6.0.0.alpha)
activerecord (= 6.0.0.alpha)
activestorage (= 6.0.0.alpha)
activesupport (= 6.0.0.alpha)
mail (>= 2.7.1)
actionmailer (6.0.0.alpha)
actionpack (= 6.0.0.alpha)
actionview (= 6.0.0.alpha)
@ -77,6 +84,7 @@ PATH
tzinfo (~> 1.1)
rails (6.0.0.alpha)
actioncable (= 6.0.0.alpha)
actionmailbox (= 6.0.0.alpha)
actionmailer (= 6.0.0.alpha)
actionpack (= 6.0.0.alpha)
actionview (= 6.0.0.alpha)
@ -129,6 +137,9 @@ GEM
aws-sdk-core (~> 3, >= 3.26.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0)
aws-sdk-sns (1.8.1)
aws-sdk-core (~> 3, >= 3.37.0)
aws-sigv4 (~> 1.0)
aws-sigv4 (1.0.3)
azure-core (0.1.14)
faraday (~> 0.9)
@ -194,6 +205,8 @@ GEM
concurrent-ruby (1.1.3)
connection_pool (2.2.2)
cookiejar (0.3.3)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.4)
curses (1.0.2)
daemons (1.2.6)
@ -269,6 +282,7 @@ GEM
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (~> 0.7)
hashdiff (0.3.7)
hiredis (0.6.3)
hiredis (0.6.3-java)
http_parser.rb (0.6.0)
@ -308,7 +322,7 @@ GEM
mime-types (3.2.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2018.0812)
mimemagic (0.3.2)
mimemagic (0.3.3)
mini_magick (4.9.2)
mini_mime (1.0.1)
mini_portile2 (2.3.0)
@ -414,6 +428,7 @@ GEM
rubyzip (1.2.2)
rufus-scheduler (3.5.2)
fugit (~> 1.1, >= 1.1.5)
safe_yaml (1.0.4)
sass (3.7.2)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
@ -493,6 +508,10 @@ GEM
json (>= 1.8)
nokogiri (~> 1.6)
wdm (0.1.1)
webmock (3.4.2)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
websocket (1.2.8)
websocket-driver (0.7.0)
websocket-extensions (>= 0.1.0)
@ -513,6 +532,7 @@ DEPENDENCIES
activerecord-jdbcpostgresql-adapter (>= 1.3.0)
activerecord-jdbcsqlite3-adapter (>= 1.3.0)
aws-sdk-s3
aws-sdk-sns
azure-storage
backburner
bcrypt (~> 3.1.11)
@ -569,8 +589,9 @@ DEPENDENCIES
uglifier (>= 1.3.0)
w3c_validators
wdm (>= 0.1.0)
webmock
webpacker!
websocket-client-simple!
BUNDLED WITH
1.17.1
1.17.2

5
actionmailbox/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.byebug_history
*.sqlite3-journal
.ruby-version
.ruby-gemset
/tmp/

21
actionmailbox/MIT-LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Basecamp, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

279
actionmailbox/README.md Normal file
View file

@ -0,0 +1,279 @@
# Action Mailbox
Action Mailbox routes incoming emails to controller-like mailboxes for processing in Rails. It ships with ingresses for Amazon SES, Mailgun, Mandrill, and SendGrid. You can also handle inbound mails directly via the built-in Postfix ingress.
The inbound emails are turned into `InboundEmail` records using Active Record and feature lifecycle tracking, storage of the original email on cloud storage via Active Storage, and responsible data handling with on-by-default incineration.
These inbound emails are routed asynchronously using Active Job to one or several dedicated mailboxes, which are capable of interacting directly with the rest of your domain model.
## How does this compare to Action Mailer's inbound processing?
Rails has long had an anemic way of [receiving emails using Action Mailer](https://guides.rubyonrails.org/action_mailer_basics.html#receiving-emails), but it was poorly fleshed out, lacked cohesion with the task of sending emails, and offered no help on integrating with popular inbound email processing platforms. Action Mailbox supersedes the receiving part of Action Mailer, which will be deprecated in due course.
## Installing
Assumes a Rails 5.2+ application:
1. Install the gem:
```ruby
# Gemfile
gem "actionmailbox", github: "rails/actionmailbox", require: "action_mailbox"
```
1. Install migrations needed for InboundEmail (and ensure Active Storage is setup)
```
./bin/rails action_mailbox:install
./bin/rails db:migrate
```
## Configuring
### Amazon SES
1. Install the [`aws-sdk-sns`](https://rubygems.org/gems/aws-sdk-sns) gem:
```ruby
# Gemfile
gem "aws-sdk-sns", ">= 1.9.0", require: false
```
2. Tell Action Mailbox to accept emails from SES:
```ruby
# config/environments/production.rb
config.action_mailbox.ingress = :amazon
```
3. [Configure SES][ses-docs] to deliver emails to your application via POST requests
to `/rails/action_mailbox/amazon/inbound_emails`. If your application lived at `https://example.com`, you would specify
the fully-qualified URL `https://example.com/rails/action_mailbox/amazon/inbound_emails`.
[ses-docs]: https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-notifications.html
### Mailgun
1. Give Action Mailbox your [Mailgun API key][mailgun-api-key] so it can authenticate requests to the Mailgun ingress.
Use `rails credentials:edit` to add your API key to your application's encrypted credentials under
`action_mailbox.mailgun_api_key`, where Action Mailbox will automatically find it:
```yaml
action_mailbox:
mailgun_api_key: ...
```
Alternatively, provide your API key in the `MAILGUN_INGRESS_API_KEY` environment variable.
2. Tell Action Mailbox to accept emails from Mailgun:
```ruby
# config/environments/production.rb
config.action_mailbox.ingress = :mailgun
```
3. [Configure Mailgun][mailgun-forwarding] to forward inbound emails to `/rails/action_mailbox/mailgun/inbound_emails/mime`.
If your application lived at `https://example.com`, you would specify the fully-qualified URL
`https://example.com/rails/action_mailbox/mailgun/inbound_emails/mime`.
[mailgun-api-key]: https://help.mailgun.com/hc/en-us/articles/203380100-Where-can-I-find-my-API-key-and-SMTP-credentials-
[mailgun-forwarding]: https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages
### Mandrill
1. Give Action Mailbox your Mandrill API key so it can authenticate requests to the Mandrill ingress.
Use `rails credentials:edit` to add your API key to your application's encrypted credentials under
`action_mailbox.mandrill_api_key`, where Action Mailbox will automatically find it:
```yaml
action_mailbox:
mandrill_api_key: ...
```
Alternatively, provide your API key in the `MANDRILL_INGRESS_API_KEY` environment variable.
2. Tell Action Mailbox to accept emails from Mandrill:
```ruby
# config/environments/production.rb
config.action_mailbox.ingress = :mandrill
```
3. [Configure Mandrill][mandrill-routing] to route inbound emails to `/rails/action_mailbox/mandrill/inbound_emails`.
If your application lived at `https://example.com`, you would specify the fully-qualified URL
`https://example.com/rails/action_mailbox/mandrill/inbound_emails`.
[mandrill-routing]: https://mandrill.zendesk.com/hc/en-us/articles/205583197-Inbound-Email-Processing-Overview
### Postfix
1. Tell Action Mailbox to accept emails from Postfix:
```ruby
# config/environments/production.rb
config.action_mailbox.ingress = :postfix
```
2. Generate a strong password that Action Mailbox can use to authenticate requests to the Postfix ingress.
Use `rails credentials:edit` to add the password to your application's encrypted credentials under
`action_mailbox.ingress_password`, where Action Mailbox will automatically find it:
```yaml
action_mailbox:
ingress_password: ...
```
Alternatively, provide the password in the `RAILS_INBOUND_EMAIL_PASSWORD` environment variable.
3. [Configure Postfix][postfix-config] to pipe inbound emails to `bin/rails action_mailbox:ingress:postfix`, providing
the `URL` of the Postfix ingress and the `INGRESS_PASSWORD` you previously generated. If your application lived at
`https://example.com`, the full command would look like this:
```
URL=https://example.com/rails/action_mailbox/postfix/inbound_emails INGRESS_PASSWORD=... bin/rails action_mailbox:ingress:postfix
```
[postfix-config]: https://serverfault.com/questions/258469/how-to-configure-postfix-to-pipe-all-incoming-email-to-a-script
### SendGrid
1. Tell Action Mailbox to accept emails from SendGrid:
```ruby
# config/environments/production.rb
config.action_mailbox.ingress = :sendgrid
```
2. Generate a strong password that Action Mailbox can use to authenticate requests to the SendGrid ingress.
Use `rails credentials:edit` to add the password to your application's encrypted credentials under
`action_mailbox.ingress_password`, where Action Mailbox will automatically find it:
```yaml
action_mailbox:
ingress_password: ...
```
Alternatively, provide the password in the `RAILS_INBOUND_EMAIL_PASSWORD` environment variable.
3. [Configure SendGrid Inbound Parse][sendgrid-config] to forward inbound emails to
`/rails/action_mailbox/sendgrid/inbound_emails` with the username `actionmailbox` and the password you previously
generated. If your application lived at `https://example.com`, you would configure SendGrid with the following URL:
```
https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/sendgrid/inbound_emails
```
**⚠️ Note:** When configuring your SendGrid Inbound Parse webhook, be sure to check the box labeled **“Post the raw,
full MIME message.”** Action Mailbox needs the raw MIME message to work.
[sendgrid-config]: https://sendgrid.com/docs/for-developers/parsing-email/setting-up-the-inbound-parse-webhook/
## Examples
Configure basic routing:
```ruby
# app/mailboxes/application_mailbox.rb
class ApplicationMailbox < ActionMailbox::Base
routing /^save@/i => :forwards
routing /@replies\./i => :replies
end
```
Then setup a mailbox:
```ruby
# Generate new mailbox
bin/rails generate mailbox forwards
```
```ruby
# app/mailboxes/forwards_mailbox.rb
class ForwardsMailbox < ApplicationMailbox
# Callbacks specify prerequisites to processing
before_processing :require_forward
def process
if forwarder.buckets.one?
record_forward
else
stage_forward_and_request_more_details
end
end
private
def require_forward
unless message.forward?
# Use Action Mailers to bounce incoming emails back to sender this halts processing
bounce_with Forwards::BounceMailer.missing_forward(
inbound_email, forwarder: forwarder
)
end
end
def forwarder
@forwarder ||= Person.where(email_address: mail.from)
end
def record_forward
forwarder.buckets.first.record \
Forward.new forwarder: forwarder, subject: message.subject, content: mail.content
end
def stage_forward_and_request_more_details
Forwards::RoutingMailer.choose_project(mail).deliver_now
end
end
```
## Incineration of InboundEmails
By default, an InboundEmail that has been successfully processed will be incinerated after 30 days. This ensures you're not holding on to people's data willy-nilly after they may have canceled their accounts or deleted their content. The intention is that after you've processed an email, you should have extracted all the data you needed and turned it into domain models and content on your side of the application. The InboundEmail simply stays in the system for the extra time to provide debugging and forensics options.
The actual incineration is done via the `IncinerationJob` that's scheduled to run after `config.action_mailbox.incinerate_after` time. This value is by default set to `30.days`, but you can change it in your production.rb configuration. (Note that this far-future incineration scheduling relies on your job queue being able to hold jobs for that long.)
## Working with Action Mailbox in development
It's helpful to be able to test incoming emails in development without actually sending and receiving real emails. To accomplish this, there's a conductor controller mounted at `/rails/conductor/action_mailbox/inbound_emails`, which gives you an index of all the InboundEmails in the system, their state of processing, and a form to create a new InboundEmail as well.
## Testing mailboxes
Example:
```ruby
class ForwardsMailboxTest < ActionMailbox::TestCase
test "directly recording a client forward for a forwarder and forwardee corresponding to one project" do
assert_difference -> { people(:david).buckets.first.recordings.count } do
receive_inbound_email_from_mail \
to: 'save@example.com',
from: people(:david).email_address,
subject: "Fwd: Status update?",
body: <<~BODY
--- Begin forwarded message ---
From: Frank Holland <frank@microsoft.com>
What's the status?
BODY
end
recording = people(:david).buckets.first.recordings.last
assert_equal people(:david), recording.creator
assert_equal "Status update?", recording.forward.subject
assert_match "What's the status?", recording.forward.content.to_s
end
end
```
## License
Action Mailbox is released under the [MIT License](https://opensource.org/licenses/MIT).

13
actionmailbox/Rakefile Normal file
View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
require "bundler/setup"
require "bundler/gem_tasks"
require "rake/testtask"
Rake::TestTask.new do |t|
t.libs << "test"
t.pattern = "test/**/*_test.rb"
t.verbose = true
end
task default: :test

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip
Gem::Specification.new do |s|
s.platform = Gem::Platform::RUBY
s.name = "actionmailbox"
s.version = version
s.summary = "Inbound email handling framework."
s.description = "Receive and process incoming emails in Rails applications."
s.required_ruby_version = ">= 2.5.0"
s.license = "MIT"
s.authors = ["David Heinemeier Hansson", "George Claghorn"]
s.email = ["david@loudthinking.com", "george@basecamp.com"]
s.homepage = "https://rubyonrails.org"
s.files = Dir["MIT-LICENSE", "README.md", "lib/**/*", "app/**/*", "config/**/*", "db/**/*"]
s.require_path = "lib"
s.metadata = {
"source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actionmailbox",
"changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actionmailbox/CHANGELOG.md"
}
# NOTE: Please read our dependency guidelines before updating versions:
# https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves
s.add_dependency "activesupport", version
s.add_dependency "activerecord", version
s.add_dependency "activestorage", version
s.add_dependency "activejob", version
s.add_dependency "actionpack", version
s.add_dependency "mail", ">= 2.7.1"
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
# The base class for all Active Mailbox ingress controllers.
class ActionMailbox::BaseController < ActionController::Base
skip_forgery_protection
before_action :ensure_configured
def self.prepare
# Override in concrete controllers to run code on load.
end
private
def ensure_configured
unless ActionMailbox.ingress == ingress_name
head :not_found
end
end
def ingress_name
self.class.name.remove(/\AActionMailbox::Ingresses::/, /::InboundEmailsController\z/).underscore.to_sym
end
def authenticate_by_password
if password.present?
http_basic_authenticate_or_request_with name: "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
end

View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
# Ingests inbound emails from Amazon's Simple Email Service (SES).
#
# Requires the full RFC 822 message in the +content+ parameter. Authenticates requests by validating their signatures.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request's signature could not be validated
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from SES
# - <tt>422 Unprocessable Entity</tt> if the request is missing the required +content+ parameter
# - <tt>500 Server Error</tt> if one of the Active Record database, the Active Storage service, or
# the Active Job backend is misconfigured or unavailable
#
# == Usage
#
# 1. Install the {aws-sdk-sns}[https://rubygems.org/gems/aws-sdk-sns] gem:
#
# # Gemfile
# gem "aws-sdk-sns", ">= 1.9.0", require: false
#
# 2. Tell Action Mailbox to accept emails from SES:
#
# # config/environments/production.rb
# config.action_mailbox.ingress = :amazon
#
# 3. {Configure SES}[https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-notifications.html]
# to deliver emails to your application via POST requests to +/rails/action_mailbox/amazon/inbound_emails+.
# If your application lived at <tt>https://example.com</tt>, you would specify the fully-qualified URL
# <tt>https://example.com/rails/action_mailbox/amazon/inbound_emails</tt>.
class ActionMailbox::Ingresses::Amazon::InboundEmailsController < ActionMailbox::BaseController
before_action :authenticate
cattr_accessor :verifier
def self.prepare
self.verifier ||= begin
require "aws-sdk-sns/message_verifier"
Aws::SNS::MessageVerifier.new
end
end
def create
ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:content)
end
private
def authenticate
head :unauthorized unless verifier.authentic?(request.body)
end
end

View file

@ -0,0 +1,101 @@
# frozen_string_literal: true
# Ingests inbound emails from Mailgun. Requires the following parameters:
#
# - +body-mime+: The full RFC 822 message
# - +timestamp+: The current time according to Mailgun as the number of seconds passed since the UNIX epoch
# - +token+: A randomly-generated, 50-character string
# - +signature+: A hexadecimal HMAC-SHA256 of the timestamp concatenated with the token, generated using the Mailgun API key
#
# Authenticates requests by validating their signatures.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request's signature could not be validated, or if its timestamp is more than 2 minutes old
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Mailgun
# - <tt>422 Unprocessable Entity</tt> if the request is missing required parameters
# - <tt>500 Server Error</tt> if the Mailgun API key is missing, or one of the Active Record database,
# the Active Storage service, or the Active Job backend is misconfigured or unavailable
#
# == Usage
#
# 1. Give Action Mailbox your {Mailgun API key}[https://help.mailgun.com/hc/en-us/articles/203380100-Where-can-I-find-my-API-key-and-SMTP-credentials-]
# so it can authenticate requests to the Mailgun ingress.
#
# Use <tt>rails credentials:edit</tt> to add your API key to your application's encrypted credentials under
# +action_mailbox.mailgun_api_key+, where Action Mailbox will automatically find it:
#
# action_mailbox:
# mailgun_api_key: ...
#
# Alternatively, provide your API key in the +MAILGUN_INGRESS_API_KEY+ environment variable.
#
# 2. Tell Action Mailbox to accept emails from Mailgun:
#
# # config/environments/production.rb
# config.action_mailbox.ingress = :mailgun
#
# 3. {Configure Mailgun}[https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages]
# to forward inbound emails to `/rails/action_mailbox/mailgun/inbound_emails/mime`.
#
# If your application lived at <tt>https://example.com</tt>, you would specify the fully-qualified URL
# <tt>https://example.com/rails/action_mailbox/mailgun/inbound_emails/mime</tt>.
class ActionMailbox::Ingresses::Mailgun::InboundEmailsController < ActionMailbox::BaseController
before_action :authenticate
def create
ActionMailbox::InboundEmail.create_and_extract_message_id! params.require("body-mime")
end
private
def authenticate
head :unauthorized unless authenticated?
end
def authenticated?
if key.present?
Authenticator.new(
key: key,
timestamp: params.require(:timestamp),
token: params.require(:token),
signature: params.require(:signature)
).authenticated?
else
raise ArgumentError, <<~MESSAGE.squish
Missing required Mailgun API key. Set action_mailbox.mailgun_api_key in your application's
encrypted credentials or provide the MAILGUN_INGRESS_API_KEY environment variable.
MESSAGE
end
end
def key
Rails.application.credentials.dig(:action_mailbox, :mailgun_api_key) || ENV["MAILGUN_INGRESS_API_KEY"]
end
class Authenticator
attr_reader :key, :timestamp, :token, :signature
def initialize(key:, timestamp:, token:, signature:)
@key, @timestamp, @token, @signature = key, Integer(timestamp), token, signature
end
def authenticated?
signed? && recent?
end
private
def signed?
ActiveSupport::SecurityUtils.secure_compare signature, expected_signature
end
# Allow for 2 minutes of drift between Mailgun time and local server time.
def recent?
Time.at(timestamp) >= 2.minutes.ago
end
def expected_signature
OpenSSL::HMAC.hexdigest OpenSSL::Digest::SHA256.new, key, "#{timestamp}#{token}"
end
end
end

View file

@ -0,0 +1,80 @@
# frozen_string_literal: true
# Ingests inbound emails from Mandrill.
#
# Requires a +mandrill_events+ parameter containing a JSON array of Mandrill inbound email event objects.
# Each event is expected to have a +msg+ object containing a full RFC 822 message in its +raw_msg+ property.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request's signature could not be validated
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Mandrill
# - <tt>422 Unprocessable Entity</tt> if the request is missing required parameters
# - <tt>500 Server Error</tt> if the Mandrill API key is missing, or one of the Active Record database,
# the Active Storage service, or the Active Job backend is misconfigured or unavailable
class ActionMailbox::Ingresses::Mandrill::InboundEmailsController < ActionMailbox::BaseController
before_action :authenticate
def create
raw_emails.each { |raw_email| ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email }
head :ok
rescue JSON::ParserError => error
logger.error error.message
head :unprocessable_entity
end
private
def raw_emails
events.select { |event| event["event"] == "inbound" }.collect { |event| event.dig("msg", "raw_msg") }
end
def events
JSON.parse params.require(:mandrill_events)
end
def authenticate
head :unauthorized unless authenticated?
end
def authenticated?
if key.present?
Authenticator.new(request, key).authenticated?
else
raise ArgumentError, <<~MESSAGE.squish
Missing required Mandrill API key. Set action_mailbox.mandrill_api_key in your application's
encrypted credentials or provide the MANDRILL_INGRESS_API_KEY environment variable.
MESSAGE
end
end
def key
Rails.application.credentials.dig(:action_mailbox, :mandrill_api_key) || ENV["MANDRILL_INGRESS_API_KEY"]
end
class Authenticator
attr_reader :request, :key
def initialize(request, key)
@request, @key = request, key
end
def authenticated?
ActiveSupport::SecurityUtils.secure_compare given_signature, expected_signature
end
private
def given_signature
request.headers["X-Mandrill-Signature"]
end
def expected_signature
Base64.strict_encode64 OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, key, message)
end
def message
request.url + request.POST.sort.flatten.join
end
end
end

View file

@ -0,0 +1,57 @@
# frozen_string_literal: true
# Ingests inbound emails relayed from Postfix.
#
# Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the
# password is read from the application's encrypted credentials or an environment variable. See the Usage section below.
#
# Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to
# the Postfix ingress can learn its password. You should only use the Postfix ingress over HTTPS.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request could not be authenticated
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Postfix
# - <tt>415 Unsupported Media Type</tt> if the request does not contain an RFC 822 message
# - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database,
# the Active Storage service, or the Active Job backend is misconfigured or unavailable
#
# == Usage
#
# 1. Tell Action Mailbox to accept emails from Postfix:
#
# # config/environments/production.rb
# config.action_mailbox.ingress = :postfix
#
# 2. Generate a strong password that Action Mailbox can use to authenticate requests to the Postfix ingress.
#
# Use <tt>rails credentials:edit</tt> to add the password to your application's encrypted credentials under
# +action_mailbox.ingress_password+, where Action Mailbox will automatically find it:
#
# action_mailbox:
# ingress_password: ...
#
# Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable.
#
# 3. {Configure Postfix}{https://serverfault.com/questions/258469/how-to-configure-postfix-to-pipe-all-incoming-email-to-a-script}
# to pipe inbound emails to <tt>bin/rails action_mailbox:ingress:postfix</tt>, providing the +URL+ of the Postfix
# ingress and the +INGRESS_PASSWORD+ you previously generated.
#
# If your application lived at <tt>https://example.com</tt>, the full command would look like this:
#
# URL=https://example.com/rails/action_mailbox/postfix/inbound_emails INGRESS_PASSWORD=... bin/rails action_mailbox:ingress:postfix
class ActionMailbox::Ingresses::Postfix::InboundEmailsController < ActionMailbox::BaseController
before_action :authenticate_by_password, :require_valid_rfc822_message
def create
ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read
end
private
def require_valid_rfc822_message
unless request.content_type == "message/rfc822"
head :unsupported_media_type
end
end
end

View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
# Ingests inbound emails from SendGrid. Requires an +email+ parameter containing a full RFC 822 message.
#
# Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the
# password is read from the application's encrypted credentials or an environment variable. See the Usage section below.
#
# Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to
# the SendGrid ingress can learn its password. You should only use the SendGrid ingress over HTTPS.
#
# Returns:
#
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
# - <tt>401 Unauthorized</tt> if the request's signature could not be validated
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from SendGrid
# - <tt>422 Unprocessable Entity</tt> if the request is missing the required +email+ parameter
# - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database,
# the Active Storage service, or the Active Job backend is misconfigured or unavailable
#
# == Usage
#
# 1. Tell Action Mailbox to accept emails from SendGrid:
#
# # config/environments/production.rb
# config.action_mailbox.ingress = :sendgrid
#
# 2. Generate a strong password that Action Mailbox can use to authenticate requests to the SendGrid ingress.
#
# Use <tt>rails credentials:edit</tt> to add the password to your application's encrypted credentials under
# +action_mailbox.ingress_password+, where Action Mailbox will automatically find it:
#
# action_mailbox:
# ingress_password: ...
#
# Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable.
#
# 3. {Configure SendGrid Inbound Parse}{https://sendgrid.com/docs/for-developers/parsing-email/setting-up-the-inbound-parse-webhook/}
# to forward inbound emails to +/rails/action_mailbox/sendgrid/inbound_emails+ with the username +actionmailbox+ and
# the password you previously generated. If your application lived at <tt>https://example.com</tt>, you would
# configure SendGrid with the following fully-qualified URL:
#
# https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/sendgrid/inbound_emails
#
# *NOTE:* When configuring your SendGrid Inbound Parse webhook, be sure to check the box labeled *"Post the raw,
# full MIME message."* Action Mailbox needs the raw MIME message to work.
class ActionMailbox::Ingresses::Sendgrid::InboundEmailsController < ActionMailbox::BaseController
before_action :authenticate_by_password
def create
ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:email)
end
end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Rails::Conductor::ActionMailbox::InboundEmailsController < Rails::Conductor::BaseController
def index
@inbound_emails = ActionMailbox::InboundEmail.order(created_at: :desc)
end
def new
end
def show
@inbound_email = ActionMailbox::InboundEmail.find(params[:id])
end
def create
inbound_email = create_inbound_email(new_mail)
redirect_to main_app.rails_conductor_inbound_email_url(inbound_email)
end
private
def new_mail
Mail.new params.require(:mail).permit(:from, :to, :cc, :bcc, :in_reply_to, :subject, :body).to_h
end
def create_inbound_email(mail)
ActionMailbox::InboundEmail.create! raw_email: \
{ io: StringIO.new(mail.to_s), filename: "inbound.eml", content_type: "message/rfc822" }
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
# Rerouting will run routing and processing on an email that has already been, or attempted to be, processed.
class Rails::Conductor::ActionMailbox::ReroutesController < Rails::Conductor::BaseController
def create
inbound_email = ActionMailbox::InboundEmail.find(params[:inbound_email_id])
reroute inbound_email
redirect_to main_app.rails_conductor_inbound_email_url(inbound_email)
end
private
def reroute(inbound_email)
inbound_email.pending!
inbound_email.route_later
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
# TODO: Move this to Rails::Conductor gem
class Rails::Conductor::BaseController < ActionController::Base
layout "rails/conductor"
before_action :ensure_development_env
private
def ensure_development_env
head :forbidden unless Rails.env.development?
end
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
# You can configure when this `IncinerationJob` will be run as a time-after-processing using the
# `config.action_mailbox.incinerate_after` or `ActionMailbox.incinerate_after` setting.
#
# Since this incineration is set for the future, it'll automatically ignore any `InboundEmail`s
# that have already been deleted and discard itself if so.
class ActionMailbox::IncinerationJob < ActiveJob::Base
queue_as { ActionMailbox.queues[:incineration] }
discard_on ActiveRecord::RecordNotFound
def self.schedule(inbound_email)
set(wait: ActionMailbox.incinerate_after).perform_later(inbound_email)
end
def perform(inbound_email)
inbound_email.incinerate
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
# Routing a new InboundEmail is an asynchronous operation, which allows the ingress controllers to quickly
# accept new incoming emails without being burdened to hang while they're actually being processed.
class ActionMailbox::RoutingJob < ActiveJob::Base
queue_as { ActionMailbox.queues[:routing] }
def perform(inbound_email)
inbound_email.route
end
end

View file

@ -0,0 +1,45 @@
# frozen_string_literal: true
require "mail"
# The `InboundEmail` is an Active Record that keeps a reference to the raw email stored in Active Storage
# and tracks the status of processing. By default, incoming emails will go through the following lifecycle:
#
# * Pending: Just received by one of the ingress controllers and scheduled for routing.
# * Processing: During active processing, while a specific mailbox is running its #process method.
# * Delivered: Successfully processed by the specific mailbox.
# * Failed: An exception was raised during the specific mailbox's execution of the `#process` method.
# * Bounced: Rejected processing by the specific mailbox and bounced to sender.
#
# Once the `InboundEmail` has reached the status of being either `delivered`, `failed`, or `bounced`,
# it'll count as having been `#processed?`. Once processed, the `InboundEmail` will be scheduled for
# automatic incineration at a later point.
#
# When working with an `InboundEmail`, you'll usually interact with the parsed version of the source,
# which is available as a `Mail` object from `#mail`. But you can also access the raw source directly
# using the `#source` method.
#
# Examples:
#
# inbound_email.mail.from # => 'david@loudthinking.com'
# inbound_email.source # Returns the full rfc822 source of the email as text
class ActionMailbox::InboundEmail < ActiveRecord::Base
self.table_name = "action_mailbox_inbound_emails"
include Incineratable, MessageId, Routable
has_one_attached :raw_email
enum status: %i[ pending processing delivered failed bounced ]
def mail
@mail ||= Mail.from_source(source)
end
def source
@source ||= raw_email.download
end
def processed?
delivered? || failed? || bounced?
end
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
# Ensure that the `InboundEmail` is automatically scheduled for later incineration if the status has been
# changed to `processed`. The later incineration will be invoked at the time specified by the
# `ActionMailbox.incinerate_after` time using the `IncinerationJob`.
module ActionMailbox::InboundEmail::Incineratable
extend ActiveSupport::Concern
included do
after_update_commit :incinerate_later, if: -> { status_previously_changed? && processed? }
end
def incinerate_later
ActionMailbox::IncinerationJob.schedule self
end
def incinerate
Incineration.new(self).run
end
end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
# Command class for carrying out the actual incineration of the `InboundMail` that's been scheduled
# for removal. Before the incineration which really is just a call to `#destroy!` is run, we verify
# that it's both eligible (by virtue of having already been processed) and time to do so (that is,
# the `InboundEmail` was processed after the `incinerate_after` time).
class ActionMailbox::InboundEmail::Incineratable::Incineration
def initialize(inbound_email)
@inbound_email = inbound_email
end
def run
@inbound_email.destroy! if due? && processed?
end
private
def due?
@inbound_email.updated_at < ActionMailbox.incinerate_after.ago.end_of_day
end
def processed?
@inbound_email.processed?
end
end

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
# The `Message-ID` as specified by rfc822 is supposed to be a unique identifier for that individual email.
# That makes it an ideal tracking token for debugging and forensics, just like `X-Request-Id` does for
# web request.
#
# If an inbound email does not, against the rfc822 mandate, specify a Message-ID, one will be generated
# using the approach from `Mail::MessageIdField`.
module ActionMailbox::InboundEmail::MessageId
extend ActiveSupport::Concern
included do
before_save :generate_missing_message_id
end
class_methods do
# Create a new `InboundEmail` from the raw `source` of the email, which be uploaded as a Active Storage
# attachment called `raw_email`. Before the upload, extract the Message-ID from the `source` and set
# it as an attribute on the new `InboundEmail`.
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(source)
Mail.from_source(source).message_id rescue nil
end
end
private
def generate_missing_message_id
self.message_id ||= Mail::MessageIdField.new.message_id.tap do |message_id|
logger.warn "Message-ID couldn't be parsed or is missing. Generated a new Message-ID: #{message_id}"
end
end
end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
# A newly received `InboundEmail` will not be routed synchronously as part of ingress controller's receival.
# Instead, the routing will be done asynchronously, using a `RoutingJob`, to ensure maximum parallel capacity.
#
# By default, all newly created `InboundEmail` records that have the status of `pending`, which is the default,
# will be scheduled for automatic, deferred routing.
module ActionMailbox::InboundEmail::Routable
extend ActiveSupport::Concern
included do
after_create_commit :route_later, if: :pending?
end
# Enqueue a `RoutingJob` for this `InboundEmail`.
def route_later
ActionMailbox::RoutingJob.perform_later self
end
# Route this `InboundEmail` using the routing rules declared on the `ApplicationMailbox`.
def route
ApplicationMailbox.route self
end
end

View file

@ -0,0 +1,7 @@
<html>
<head>
<title>Rails Conductor: <%= yield :title %></title>
</head>
<body>
<%= yield %>
</html>

View file

@ -0,0 +1,15 @@
<% provide :title, "Deliver new inbound email" %>
<h1>All inbound emails</h1>
<table>
<tr><th>Message ID</th><th>Status</th></tr>
<% @inbound_emails.each do |inbound_email| %>
<tr>
<td><%= link_to inbound_email.message_id, main_app.rails_conductor_inbound_email_path(inbound_email) %></td>
<td><%= inbound_email.status %></td>
</tr>
<% end %>
</table>
<%= link_to "Deliver new inbound email", main_app.new_rails_conductor_inbound_email_path %>

View file

@ -0,0 +1,42 @@
<% provide :title, "Deliver new inbound email" %>
<h1>Deliver new inbound email</h1>
<%= form_with(url: main_app.rails_conductor_inbound_emails_path, scope: :mail, local: true) do |form| %>
<div>
<%= form.label :from, "From" %><br>
<%= form.text_field :from %>
</div>
<div>
<%= form.label :to, "To" %><br>
<%= form.text_field :to %>
</div>
<div>
<%= form.label :cc, "CC" %><br>
<%= form.text_field :cc %>
</div>
<div>
<%= form.label :bcc, "BCC" %><br>
<%= form.text_field :bcc %>
</div>
<div>
<%= form.label :in_reply_to, "In-Reply-To" %><br>
<%= form.text_field :in_reply_to %>
</div>
<div>
<%= form.label :subject, "Subject" %><br>
<%= form.text_field :subject %>
</div>
<div>
<%= form.label :body, "Body" %><br>
<%= form.text_area :body, size: "40x20" %>
</div>
<%= form.submit "Deliver inbound email" %>
<% end %>

View file

@ -0,0 +1,15 @@
<% provide :title, @inbound_email.message_id %>
<h1><%= @inbound_email.message_id %>: <%= @inbound_email.status %></h1>
<ul>
<li><%= button_to "Route again", main_app.rails_conductor_inbound_email_reroute_path(@inbound_email), method: :post %></li>
<li>Incinerate</li>
</ul>
<details>
<summary>Full email source</summary>
<pre><%= @inbound_email.source %></pre>
</details>
<%= link_to "Back to all inbound emails", main_app.rails_conductor_inbound_emails_path %>

5
actionmailbox/bin/test Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
COMPONENT_ROOT = File.expand_path("..", __dir__)
require_relative "../../tools/test"

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
Rails.application.routes.draw do
scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do
post "/amazon/inbound_emails" => "amazon/inbound_emails#create", as: :rails_amazon_inbound_emails
post "/mandrill/inbound_emails" => "mandrill/inbound_emails#create", as: :rails_mandrill_inbound_emails
post "/postfix/inbound_emails" => "postfix/inbound_emails#create", as: :rails_postfix_inbound_emails
post "/sendgrid/inbound_emails" => "sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails
# Mailgun requires that a webhook's URL end in 'mime' for it to receive the raw contents of emails.
post "/mailgun/inbound_emails/mime" => "mailgun/inbound_emails#create", as: :rails_mailgun_inbound_emails
end
# TODO: Should these be mounted within the engine only?
scope "rails/conductor/action_mailbox/", module: "rails/conductor/action_mailbox" do
resources :inbound_emails, as: :rails_conductor_inbound_emails
post ":inbound_email_id/reroute" => "reroutes#create", as: :rails_conductor_inbound_email_reroute
end
end

View file

@ -0,0 +1,11 @@
class CreateActionMailboxTables < ActiveRecord::Migration[5.2]
def change
create_table :action_mailbox_inbound_emails do |t|
t.integer :status, default: 0, null: false
t.string :message_id
t.datetime :created_at, precision: 6
t.datetime :updated_at, precision: 6
end
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
require "action_mailbox/mail_ext"
module ActionMailbox
extend ActiveSupport::Autoload
autoload :Base
autoload :Router
autoload :TestCase
mattr_accessor :ingress
mattr_accessor :logger
mattr_accessor :incinerate_after, default: 30.days
mattr_accessor :queues, default: {}
end

View file

@ -0,0 +1,113 @@
# frozen_string_literal: true
require "active_support/rescuable"
require "action_mailbox/callbacks"
require "action_mailbox/routing"
# The base class for all application mailboxes. Not intended to be inherited from directly. Inherit from
# `ApplicationMailbox` instead, as that's where the app-specific routing is configured. This routing
# is specified in the following ways:
#
# class ApplicationMailbox < ActionMailbox::Base
# # Any of the recipients of the mail (whether to, cc, bcc) are matched against the regexp.
# routing /^replies@/i => :replies
#
# # Any of the recipients of the mail (whether to, cc, bcc) needs to be an exact match for the string.
# routing "help@example.com" => :help
#
# # Any callable (proc, lambda, etc) object is passed the inbound_email record and is a match if true.
# routing ->(inbound_email) { inbound_email.mail.to.size > 2 } => :multiple_recipients
#
# # Any object responding to #match? is called with the inbound_email record as an argument. Match if true.
# routing CustomAddress.new => :custom
#
# # Any inbound_email that has not been already matched will be sent to the BackstopMailbox.
# routing :all => :backstop
# end
#
# Application mailboxes need to overwrite the `#process` method, which is invoked by the framework after
# callbacks have been run. The callbacks available are: `before_processing`, `after_processing`, and
# `around_processing`. The primary use case is ensure certain preconditions to processing are fulfilled
# using `before_processing` callbacks.
#
# If a precondition fails to be met, you can halt the processing using the `#bounced!` method,
# which will silently prevent any further processing, but not actually send out any bounce notice. You
# can also pair this behavior with the invocation of an Action Mailer class responsible for sending out
# an actual bounce email. This is done using the `#bounce_with` method, which takes the mail object returned
# by an Action Mailer method, like so:
#
# class ForwardsMailbox < ApplicationMailbox
# before_processing :ensure_sender_is_a_user
#
# private
# def ensure_sender_is_a_user
# unless User.exist?(email_address: mail.from)
# bounce_with UserRequiredMailer.missing(inbound_email)
# end
# end
# end
#
# During the processing of the inbound email, the status will be tracked. Before processing begins,
# the email will normally have the `pending` status. Once processing begins, just before callbacks
# and the `#process` method is called, the status is changed to `processing`. If processing is allowed to
# complete, the status is changed to `delivered`. If a bounce is triggered, then `bounced`. If an unhandled
# exception is bubbled up, then `failed`.
#
# Exceptions can be handled at the class level using the familiar `Rescuable` approach:
#
# class ForwardsMailbox < ApplicationMailbox
# rescue_from(ApplicationSpecificVerificationError) { bounced! }
# end
class ActionMailbox::Base
include ActiveSupport::Rescuable
include ActionMailbox::Callbacks, ActionMailbox::Routing
attr_reader :inbound_email
delegate :mail, :delivered!, :bounced!, to: :inbound_email
delegate :logger, to: ActionMailbox
def self.receive(inbound_email)
new(inbound_email).perform_processing
end
def initialize(inbound_email)
@inbound_email = inbound_email
end
def perform_processing
track_status_of_inbound_email do
run_callbacks :process do
process
end
end
rescue => exception
# TODO: Include a reference to the inbound_email in the exception raised so error handling becomes easier
rescue_with_handler(exception) || raise
end
def process
# Overwrite in subclasses
end
def finished_processing?
inbound_email.delivered? || inbound_email.bounced?
end
def bounce_with(message)
inbound_email.bounced!
message.deliver_later
end
private
def track_status_of_inbound_email
inbound_email.processing!
yield
inbound_email.delivered! unless inbound_email.bounced?
rescue
inbound_email.failed!
raise
end
end

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
require "active_support/callbacks"
module ActionMailbox
# Defines the callbacks related to processing.
module Callbacks
extend ActiveSupport::Concern
include ActiveSupport::Callbacks
TERMINATOR = ->(mailbox, chain) do
chain.call
mailbox.finished_processing?
end
included do
define_callbacks :process, terminator: TERMINATOR, skip_after_callbacks_if_terminated: true
end
class_methods do
def before_processing(*methods, &block)
set_callback(:process, :before, *methods, &block)
end
def after_processing(*methods, &block)
set_callback(:process, :after, *methods, &block)
end
def around_processing(*methods, &block)
set_callback(:process, :around, *methods, &block)
end
end
end
end

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
require "rails/engine"
require "action_mailbox"
module ActionMailbox
class Engine < Rails::Engine
isolate_namespace ActionMailbox
config.eager_load_namespaces << ActionMailbox
config.action_mailbox = ActiveSupport::OrderedOptions.new
config.action_mailbox.incinerate_after = 30.days
config.action_mailbox.queues = ActiveSupport::InheritableOptions.new \
incineration: :action_mailbox_incineration, routing: :action_mailbox_routing
initializer "action_mailbox.config" do
config.after_initialize do |app|
ActionMailbox.logger = app.config.action_mailbox.logger || Rails.logger
ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days
ActionMailbox.queues = app.config.action_mailbox.queues || {}
end
end
initializer "action_mailbox.ingress" do
config.after_initialize do |app|
if ActionMailbox.ingress = app.config.action_mailbox.ingress.presence
config.to_prepare do
if ingress_controller_class = "ActionMailbox::Ingresses::#{ActionMailbox.ingress.to_s.classify}::InboundEmailsController".safe_constantize
ingress_controller_class.prepare
end
end
end
end
end
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
module ActionMailbox
# Returns the currently-loaded version of Action Mailbox as a <tt>Gem::Version</tt>.
def self.gem_version
Gem::Version.new VERSION::STRING
end
module VERSION
MAJOR = 6
MINOR = 0
TINY = 0
PRE = "alpha"
STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
end
end

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
require "mail"
# The hope is to upstream most of these basic additions to the Mail gem's Mail object. But until then, here they lay!
Dir["#{File.expand_path(File.dirname(__FILE__))}/mail_ext/*"].each { |path| require "action_mailbox/mail_ext/#{File.basename(path)}" }

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Mail::Address
def ==(other_address)
other_address.is_a?(Mail::Address) && to_s == other_address.to_s
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Mail::Address
def self.wrap(address)
address.is_a?(Mail::Address) ? address : Mail::Address.new(address)
end
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class Mail::Message
def from_address
header[:from]&.address_list&.addresses&.first
end
def recipients_addresses
to_addresses + cc_addresses + bcc_addresses + x_original_to_addresses
end
def to_addresses
Array(header[:to]&.address_list&.addresses)
end
def cc_addresses
Array(header[:cc]&.address_list&.addresses)
end
def bcc_addresses
Array(header[:bcc]&.address_list&.addresses)
end
def x_original_to_addresses
Array(header[:x_original_to]).collect { |header| Mail::Address.new header.to_s }
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
module Mail
def self.from_source(source)
Mail.new Mail::Utilities.binary_unsafe_to_crlf(source.to_s)
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Mail::Message
def recipients
Array(to) + Array(cc) + Array(bcc) + Array(header[:x_original_to]).map(&:to_s)
end
end

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
require "action_mailbox/version"
require "net/http"
require "uri"
module ActionMailbox
class PostfixRelayer
class Result < Struct.new(:output)
def success?
!failure?
end
def failure?
output.match?(/\A[45]\.\d{1,3}\.\d{1,3}(\s|\z)/)
end
end
CONTENT_TYPE = "message/rfc822"
USER_AGENT = "Action Mailbox Postfix relayer v#{ActionMailbox.version}"
attr_reader :uri, :username, :password
def initialize(url:, username: "actionmailbox", password:)
@uri, @username, @password = URI(url), username, password
end
def relay(source)
case response = post(source)
when Net::HTTPSuccess
Result.new "2.0.0 Successfully relayed message to Postfix ingress"
when Net::HTTPUnauthorized
Result.new "4.7.0 Invalid credentials for Postfix ingress"
else
Result.new "4.0.0 HTTP #{response.code}"
end
rescue IOError, SocketError, SystemCallError => error
Result.new "4.4.2 Network error relaying to Postfix ingress: #{error.message}"
rescue Timeout::Error
Result.new "4.4.2 Timed out relaying to Postfix ingress"
rescue => error
Result.new "4.0.0 Error relaying to Postfix ingress: #{error.message}"
end
private
def post(source)
client.post uri, source,
"Content-Type" => CONTENT_TYPE,
"User-Agent" => USER_AGENT,
"Authorization" => "Basic #{Base64.strict_encode64(username + ":" + password)}"
end
def client
@client ||= Net::HTTP.new(uri.host, uri.port).tap do |connection|
if uri.scheme == "https"
require "openssl"
connection.use_ssl = true
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
end
connection.open_timeout = 1
connection.read_timeout = 10
end
end
end
end

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
# Encapsulates the routes that live on the ApplicationMailbox and performs the actual routing when
# an inbound_email is received.
class ActionMailbox::Router
class RoutingError < StandardError; end
def initialize
@routes = []
end
def add_routes(routes)
routes.each do |(address, mailbox_name)|
add_route address, to: mailbox_name
end
end
def add_route(address, to:)
routes.append Route.new(address, to: to)
end
def route(inbound_email)
if mailbox = match_to_mailbox(inbound_email)
mailbox.receive(inbound_email)
else
inbound_email.bounced!
raise RoutingError
end
end
private
attr_reader :routes
def match_to_mailbox(inbound_email)
routes.detect { |route| route.match?(inbound_email) }.try(:mailbox_class)
end
end
require "action_mailbox/router/route"

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
# Encapsulates a route, which can then be matched against an inbound_email and provide a lookup of the matching
# mailbox class. See examples for the different route addresses and how to use them in the `ActionMailbox::Base`
# documentation.
class ActionMailbox::Router::Route
attr_reader :address, :mailbox_name
def initialize(address, to:)
@address, @mailbox_name = address, to
ensure_valid_address
end
def match?(inbound_email)
case address
when :all
true
when String
inbound_email.mail.recipients.any? { |recipient| address.casecmp?(recipient) }
when Regexp
inbound_email.mail.recipients.any? { |recipient| address.match?(recipient) }
when Proc
address.call(inbound_email)
else
address.match?(inbound_email)
end
end
def mailbox_class
"#{mailbox_name.to_s.camelize}Mailbox".constantize
end
private
def ensure_valid_address
unless [ Symbol, String, Regexp, Proc ].any? { |klass| address.is_a?(klass) } || address.respond_to?(:match?)
raise ArgumentError, "Expected a Symbol, String, Regexp, Proc, or matchable, got #{address.inspect}"
end
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
module ActionMailbox
# See `ActionMailbox::Base` for how to specify routing.
module Routing
extend ActiveSupport::Concern
included do
cattr_accessor :router, default: ActionMailbox::Router.new
end
class_methods do
def routing(routes)
router.add_routes(routes)
end
def route(inbound_email)
router.route(inbound_email)
end
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
require "action_mailbox/test_helper"
require "active_support/test_case"
module ActionMailbox
class TestCase < ActiveSupport::TestCase
include ActionMailbox::TestHelper
end
end

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
require "mail"
module ActionMailbox
module TestHelper
# Create an `InboundEmail` record using an eml fixture in the format of message/rfc822
# referenced with +fixture_name+ located in +test/fixtures/files/fixture_name+.
def create_inbound_email_from_fixture(fixture_name, status: :processing)
create_inbound_email_from_source file_fixture(fixture_name).read, status: status
end
# Create an `InboundEmail` by specifying it using `Mail.new` options. Example:
#
# create_inbound_email_from_mail(from: "david@loudthinking.com", subject: "Hello!")
def create_inbound_email_from_mail(status: :processing, **mail_options)
create_inbound_email_from_source Mail.new(mail_options).to_s, status: status
end
# Create an `InboundEmail` using the raw rfc822 `source` as text.
def create_inbound_email_from_source(source, status: :processing)
ActionMailbox::InboundEmail.create_and_extract_message_id! source, status: status
end
# Create an `InboundEmail` from fixture using the same arguments as `create_inbound_email_from_fixture`
# and immediately route it to processing.
def receive_inbound_email_from_fixture(*args)
create_inbound_email_from_fixture(*args).tap(&:route)
end
# Create an `InboundEmail` from fixture using the same arguments as `create_inbound_email_from_mail`
# and immediately route it to processing.
def receive_inbound_email_from_mail(**kwargs)
create_inbound_email_from_mail(**kwargs).tap(&:route)
end
# Create an `InboundEmail` from fixture using the same arguments as `create_inbound_email_from_source`
# and immediately route it to processing.
def receive_inbound_email_from_source(**kwargs)
create_inbound_email_from_source(**kwargs).tap(&:route)
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
require_relative "gem_version"
module ActionMailbox
# Returns the currently-loaded version of Action Mailbox as a <tt>Gem::Version</tt>.
def self.version
gem_version
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
say "Copying application_mailbox.rb to app/mailboxes"
copy_file "#{__dir__}/mailbox/templates/application_mailbox.rb", "app/mailboxes/application_mailbox.rb"
environment <<~end_of_config, env: "production"
# Prepare the ingress controller used to receive mail
# config.action_mailbox.ingress = :amazon
end_of_config

View file

@ -0,0 +1,12 @@
Description:
============
Stubs out a new mailbox class in app/mailboxes and invokes your template
engine and test framework generators.
Example:
========
rails generate mailbox inbox
creates a InboxMailbox class and test:
Mailbox: app/mailboxes/inbox_mailbox.rb
Test: test/mailboxes/inbox_mailbox_test.rb

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
module Rails
module Generators
class MailboxGenerator < NamedBase
source_root File.expand_path("templates", __dir__)
check_class_collision suffix: "Mailbox"
def create_mailbox_file
template "mailbox.rb", File.join("app/mailboxes", class_path, "#{file_name}_mailbox.rb")
in_root do
if behavior == :invoke && !File.exist?(application_mailbox_file_name)
template "application_mailbox.rb", application_mailbox_file_name
end
end
end
hook_for :test_framework
private
def file_name # :doc:
@_file_name ||= super.sub(/_mailbox\z/i, "")
end
def application_mailbox_file_name
"app/mailboxes/application_mailbox.rb"
end
end
end
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class ApplicationMailbox < ActionMailbox::Base
# routing /something/i => :somewhere
end

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
class <%= class_name %>Mailbox < ApplicationMailbox
def process
end
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
module TestUnit
module Generators
class MailboxGenerator < ::Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
check_class_collision suffix: "MailboxTest"
def create_test_files
template "mailbox_test.rb", File.join("test/mailboxes", class_path, "#{file_name}_mailbox_test.rb")
end
private
def file_name # :doc:
@_file_name ||= super.sub(/_mailbox\z/i, "")
end
end
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
require "test_helper"
class <%= class_name %>MailboxTest < ActionMailbox::TestCase
# test "receive mail" do
# receive_inbound_email_from_mail \
# to: '"someone" <someone@example.com>,
# from: '"else" <else@example.com>',
# subject: "Hello world!",
# body: "Hello?"
# end
end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
namespace :action_mailbox do
namespace :ingress do
desc "Pipe an inbound email from STDIN to the Postfix ingress (URL and INGRESS_PASSWORD required)"
task :postfix do
require "active_support"
require "active_support/core_ext/object/blank"
require "action_mailbox/postfix_relayer"
url, password = ENV.values_at("URL", "INGRESS_PASSWORD")
if url.blank? || password.blank?
print "4.3.5 URL and INGRESS_PASSWORD are required"
exit 1
end
ActionMailbox::PostfixRelayer.new(url: url, password: password).relay(STDIN.read).tap do |result|
print result.output
exit result.success?
end
end
end
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
namespace :action_mailbox do
# Prevent migration installation task from showing up twice.
Rake::Task["install:migrations"].clear_comments
desc "Copy over the migration"
task install: %w[ environment run_installer copy_migrations ]
task :run_installer do
installer_template = File.expand_path("../rails/generators/installer.rb", __dir__)
system "#{RbConfig.ruby} ./bin/rails app:template LOCATION=#{installer_template}"
end
task :copy_migrations do
Rake::Task["active_storage:install:migrations"].invoke
Rake::Task["railties:install:migrations"].reenable # Otherwise you can't run 2 migration copy tasks in one invocation
Rake::Task["action_mailbox:install:migrations"].invoke
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
require "test_helper"
ActionMailbox::Ingresses::Amazon::InboundEmailsController.verifier =
Module.new { def self.authentic?(message); true; end }
class ActionMailbox::Ingresses::Amazon::InboundEmailsControllerTest < ActionDispatch::IntegrationTest
setup { ActionMailbox.ingress = :amazon }
test "receiving an inbound email from Amazon" do
assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do
post rails_amazon_inbound_emails_url, params: { content: file_fixture("../files/welcome.eml").read }, as: :json
end
assert_response :no_content
inbound_email = ActionMailbox::InboundEmail.last
assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download
assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id
end
end

View file

@ -0,0 +1,91 @@
# frozen_string_literal: true
require "test_helper"
ENV["MAILGUN_INGRESS_API_KEY"] = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL"
class ActionMailbox::Ingresses::Mailgun::InboundEmailsControllerTest < ActionDispatch::IntegrationTest
setup { ActionMailbox.ingress = :mailgun }
test "receiving an inbound email from Mailgun" do
assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do
travel_to "2018-10-09 15:15:00 EDT"
post rails_mailgun_inbound_emails_url, params: {
timestamp: 1539112500,
token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi",
signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc",
"body-mime" => file_fixture("../files/welcome.eml").read
}
end
assert_response :no_content
inbound_email = ActionMailbox::InboundEmail.last
assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download
assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id
end
test "rejecting a delayed inbound email from Mailgun" do
assert_no_difference -> { ActionMailbox::InboundEmail.count } do
travel_to "2018-10-09 15:26:00 EDT"
post rails_mailgun_inbound_emails_url, params: {
timestamp: 1539112500,
token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi",
signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc",
"body-mime" => file_fixture("../files/welcome.eml").read
}
end
assert_response :unauthorized
end
test "rejecting a forged inbound email from Mailgun" do
assert_no_difference -> { ActionMailbox::InboundEmail.count } do
travel_to "2018-10-09 15:15:00 EDT"
post rails_mailgun_inbound_emails_url, params: {
timestamp: 1539112500,
token: "Zx8mJBiGmiiyyfWnho3zKyjCg2pxLARoCuBM7X9AKCioShGiMX",
signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc",
"body-mime" => file_fixture("../files/welcome.eml").read
}
end
assert_response :unauthorized
end
test "raising when the configured Mailgun API key is nil" do
switch_key_to nil do
assert_raises ArgumentError do
travel_to "2018-10-09 15:15:00 EDT"
post rails_mailgun_inbound_emails_url, params: {
timestamp: 1539112500,
token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi",
signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc",
"body-mime" => file_fixture("../files/welcome.eml").read
}
end
end
end
test "raising when the configured Mailgun API key is blank" do
switch_key_to "" do
assert_raises ArgumentError do
travel_to "2018-10-09 15:15:00 EDT"
post rails_mailgun_inbound_emails_url, params: {
timestamp: 1539112500,
token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi",
signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc",
"body-mime" => file_fixture("../files/welcome.eml").read
}
end
end
end
private
def switch_key_to(new_key)
previous_key, ENV["MAILGUN_INGRESS_API_KEY"] = ENV["MAILGUN_INGRESS_API_KEY"], new_key
yield
ensure
ENV["MAILGUN_INGRESS_API_KEY"] = previous_key
end
end

View file

@ -0,0 +1,60 @@
# frozen_string_literal: true
require "test_helper"
ENV["MANDRILL_INGRESS_API_KEY"] = "1l9Qf7lutEf7h73VXfBwhw"
class ActionMailbox::Ingresses::Mandrill::InboundEmailsControllerTest < ActionDispatch::IntegrationTest
setup do
ActionMailbox.ingress = :mandrill
@events = JSON.generate([{ event: "inbound", msg: { raw_msg: file_fixture("../files/welcome.eml").read } }])
end
test "receiving an inbound email from Mandrill" do
assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do
post rails_mandrill_inbound_emails_url,
headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events }
end
assert_response :ok
inbound_email = ActionMailbox::InboundEmail.last
assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download
assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id
end
test "rejecting a forged inbound email from Mandrill" do
assert_no_difference -> { ActionMailbox::InboundEmail.count } do
post rails_mandrill_inbound_emails_url,
headers: { "X-Mandrill-Signature" => "forged" }, params: { mandrill_events: @events }
end
assert_response :unauthorized
end
test "raising when Mandrill API key is nil" do
switch_key_to nil do
assert_raises ArgumentError do
post rails_mandrill_inbound_emails_url,
headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events }
end
end
end
test "raising when Mandrill API key is blank" do
switch_key_to "" do
assert_raises ArgumentError do
post rails_mandrill_inbound_emails_url,
headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events }
end
end
end
private
def switch_key_to(new_key)
previous_key, ENV["MANDRILL_INGRESS_API_KEY"] = ENV["MANDRILL_INGRESS_API_KEY"], new_key
yield
ensure
ENV["MANDRILL_INGRESS_API_KEY"] = previous_key
end
end

View file

@ -0,0 +1,56 @@
# frozen_string_literal: true
require "test_helper"
class ActionMailbox::Ingresses::Postfix::InboundEmailsControllerTest < ActionDispatch::IntegrationTest
setup { ActionMailbox.ingress = :postfix }
test "receiving an inbound email from Postfix" do
assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do
post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" },
params: file_fixture("../files/welcome.eml").read
end
assert_response :no_content
inbound_email = ActionMailbox::InboundEmail.last
assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download
assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id
end
test "rejecting an unauthorized inbound email from Postfix" do
assert_no_difference -> { ActionMailbox::InboundEmail.count } do
post rails_postfix_inbound_emails_url, headers: { "Content-Type" => "message/rfc822" },
params: file_fixture("../files/welcome.eml").read
end
assert_response :unauthorized
end
test "rejecting an inbound email of an unsupported media type from Postfix" do
assert_no_difference -> { ActionMailbox::InboundEmail.count } do
post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "text/plain" },
params: file_fixture("../files/welcome.eml").read
end
assert_response :unsupported_media_type
end
test "raising when the configured password is nil" do
switch_password_to nil do
assert_raises ArgumentError do
post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" },
params: file_fixture("../files/welcome.eml").read
end
end
end
test "raising when the configured password is blank" do
switch_password_to "" do
assert_raises ArgumentError do
post rails_postfix_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" },
params: file_fixture("../files/welcome.eml").read
end
end
end
end

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
require "test_helper"
class ActionMailbox::Ingresses::Sendgrid::InboundEmailsControllerTest < ActionDispatch::IntegrationTest
setup { ActionMailbox.ingress = :sendgrid }
test "receiving an inbound email from Sendgrid" do
assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do
post rails_sendgrid_inbound_emails_url,
headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read }
end
assert_response :no_content
inbound_email = ActionMailbox::InboundEmail.last
assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download
assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id
end
test "rejecting an unauthorized inbound email from Sendgrid" do
assert_no_difference -> { ActionMailbox::InboundEmail.count } do
post rails_sendgrid_inbound_emails_url, params: { email: file_fixture("../files/welcome.eml").read }
end
assert_response :unauthorized
end
test "raising when the configured password is nil" do
switch_password_to nil do
assert_raises ArgumentError do
post rails_sendgrid_inbound_emails_url,
headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read }
end
end
end
test "raising when the configured password is blank" do
switch_password_to "" do
assert_raises ArgumentError do
post rails_sendgrid_inbound_emails_url,
headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read }
end
end
end
end

View file

@ -0,0 +1,18 @@
{
"presets": [
["env", {
"modules": false,
"targets": {
"browsers": "> 1%",
"uglify": true
},
"useBuiltIns": true
}]
],
"plugins": [
"syntax-dynamic-import",
"transform-object-rest-spread",
["transform-class-properties", { "spec": true }]
]
}

3
actionmailbox/test/dummy/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*.log
*.sqlite3
tmp/*

View file

@ -0,0 +1,3 @@
plugins:
postcss-import: {}
postcss-cssnext: {}

View file

@ -0,0 +1,6 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative 'config/application'
Rails.application.load_tasks

View file

@ -0,0 +1,3 @@
//= link_tree ../images
//= link_directory ../javascripts .js
//= link_directory ../stylesheets .css

View file

@ -0,0 +1,15 @@
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
* files in this directory. Styles in this file should be added after the last require_* statement.
* It is generally better to create a new file per style scope.
*
*= require_tree .
*= require_self
*/

View file

@ -0,0 +1,80 @@
body {
background-color: #fff;
color: #333;
margin: 33px;
}
body, p, ol, ul, td {
font-family: verdana, arial, helvetica, sans-serif;
font-size: 13px;
line-height: 18px;
}
pre {
background-color: #eee;
padding: 10px;
font-size: 11px;
}
a {
color: #000;
}
a:visited {
color: #666;
}
a:hover {
color: #fff;
background-color: #000;
}
th {
padding-bottom: 5px;
}
td {
padding: 0 5px 7px;
}
div.field,
div.actions {
margin-bottom: 10px;
}
#notice {
color: green;
}
.field_with_errors {
padding: 2px;
background-color: red;
display: table;
}
#error_explanation {
width: 450px;
border: 2px solid red;
padding: 7px 7px 0;
margin-bottom: 20px;
background-color: #f0f0f0;
}
#error_explanation h2 {
text-align: left;
font-weight: bold;
padding: 5px 5px 5px 15px;
font-size: 12px;
margin: -7px -7px 0;
background-color: #c00;
color: #fff;
}
#error_explanation ul li {
font-size: 12px;
list-style: square;
}
label {
display: block;
}

View file

@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View file

@ -0,0 +1,4 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end

View file

@ -0,0 +1,2 @@
class ApplicationController < ActionController::Base
end

View file

@ -0,0 +1,2 @@
module ApplicationHelper
end

View file

@ -0,0 +1,2 @@
class ApplicationJob < ActiveJob::Base
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class ApplicationMailbox < ActionMailbox::Base
# routing /something/i => :somewhere
end

View file

@ -0,0 +1,4 @@
class MessagesMailbox < ApplicationMailbox
def process
end
end

View file

@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: 'from@example.com'
layout 'mailer'
end

View file

@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Dummy</title>
<%= csrf_meta_tags %>
<%= stylesheet_link_tag 'application', media: 'all' %>
<%= javascript_pack_tag 'application' %>
</head>
<body>
<%= yield %>
</body>
</html>

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html>

View file

@ -0,0 +1 @@
<%= yield %>

View file

@ -0,0 +1,3 @@
#!/usr/bin/env ruby
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
load Gem.bin_path('bundler', 'bundle')

View file

@ -0,0 +1,4 @@
#!/usr/bin/env ruby
APP_PATH = File.expand_path('../config/application', __dir__)
require_relative '../config/boot'
require 'rails/commands'

View file

@ -0,0 +1,4 @@
#!/usr/bin/env ruby
require_relative '../config/boot'
require 'rake'
Rake.application.run

View file

@ -0,0 +1,36 @@
#!/usr/bin/env ruby
require 'fileutils'
include FileUtils
# path to your application root.
APP_ROOT = File.expand_path('..', __dir__)
def system!(*args)
system(*args) || abort("\n== Command #{args} failed ==")
end
chdir APP_ROOT do
# This script is a starting point to setup your application.
# Add necessary setup steps to this file.
puts '== Installing dependencies =='
system! 'gem install bundler --conservative'
system('bundle check') || system!('bundle install')
# Install JavaScript dependencies if using Yarn
# system('bin/yarn')
# puts "\n== Copying sample files =="
# unless File.exist?('config/database.yml')
# cp 'config/database.yml.sample', 'config/database.yml'
# end
puts "\n== Preparing database =="
system! 'bin/rails db:setup'
puts "\n== Removing old logs and tempfiles =="
system! 'bin/rails log:clear tmp:clear'
puts "\n== Restarting application server =="
system! 'bin/rails restart'
end

View file

@ -0,0 +1,31 @@
#!/usr/bin/env ruby
require 'fileutils'
include FileUtils
# path to your application root.
APP_ROOT = File.expand_path('..', __dir__)
def system!(*args)
system(*args) || abort("\n== Command #{args} failed ==")
end
chdir APP_ROOT do
# This script is a way to update your development environment automatically.
# Add necessary update steps to this file.
puts '== Installing dependencies =='
system! 'gem install bundler --conservative'
system('bundle check') || system!('bundle install')
# Install JavaScript dependencies if using Yarn
# system('bin/yarn')
puts "\n== Updating database =="
system! 'bin/rails db:migrate'
puts "\n== Removing old logs and tempfiles =="
system! 'bin/rails log:clear tmp:clear'
puts "\n== Restarting application server =="
system! 'bin/rails restart'
end

View file

@ -0,0 +1,11 @@
#!/usr/bin/env ruby
APP_ROOT = File.expand_path('..', __dir__)
Dir.chdir(APP_ROOT) do
begin
exec "yarnpkg", *ARGV
rescue Errno::ENOENT
$stderr.puts "Yarn executable was not detected in the system."
$stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install"
exit 1
end
end

View file

@ -0,0 +1,5 @@
# This file is used by Rack-based servers to start the application.
require_relative 'config/environment'
run Rails.application

View file

@ -0,0 +1,17 @@
require_relative 'boot'
require 'rails/all'
Bundler.require(*Rails.groups)
module Dummy
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 5.2
# Settings in config/environments/* take precedence over those specified here.
# Application configuration can go into files in config/initializers
# -- all .rb files in that directory are automatically loaded after loading
# the framework and any gems in your application.
end
end

View file

@ -0,0 +1,5 @@
# Set up gems listed in the Gemfile.
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__)
require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
$LOAD_PATH.unshift File.expand_path('../../../lib', __dir__)

View file

@ -0,0 +1,10 @@
development:
adapter: async
test:
adapter: async
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: dummy_production

View file

@ -0,0 +1,25 @@
# SQLite version 3.x
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem 'sqlite3'
#
default: &default
adapter: sqlite3
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: db/development.sqlite3
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: db/test.sqlite3
production:
<<: *default
database: db/production.sqlite3

View file

@ -0,0 +1,5 @@
# Load the Rails application.
require_relative 'application'
# Initialize the Rails application.
Rails.application.initialize!

View file

@ -0,0 +1,63 @@
Rails.application.configure do
# Verifies that versions and hashed value of the package contents in the project's package.json
# config.webpacker.check_yarn_integrity = true
# Settings specified here will take precedence over those in config/application.rb.
# In the development environment your application's code is reloaded on
# every request. This slows down response time but is perfect for development
# since you don't have to restart the web server when you make code changes.
config.cache_classes = false
# Do not eager load code on boot.
config.eager_load = false
# Show full error reports.
config.consider_all_requests_local = true
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join('tmp', 'caching-dev.txt').exist?
config.action_controller.perform_caching = true
config.cache_store = :memory_store
config.public_file_server.headers = {
'Cache-Control' => "public, max-age=#{2.days.to_i}"
}
else
config.action_controller.perform_caching = false
config.cache_store = :null_store
end
# Store uploaded files on the local file system (see config/storage.yml for options)
config.active_storage.service = :local
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false
config.action_mailer.perform_caching = false
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
# Raise an error on page load if there are pending migrations.
config.active_record.migration_error = :page_load
# Highlight code that triggered database queries in logs.
config.active_record.verbose_query_logs = true
# Debug mode disables concatenation and preprocessing of assets.
# This option may cause significant delays in view rendering with a large
# number of complex assets.
config.assets.debug = true
# Suppress logger output for asset requests.
config.assets.quiet = true
# Raises error for missing translations
# config.action_view.raise_on_missing_translations = true
# Use an evented file watcher to asynchronously detect changes in source code,
# routes, locales, etc. This feature depends on the listen gem.
# config.file_watcher = ActiveSupport::EventedFileUpdateChecker
end

Some files were not shown because too many files have changed in this diff Show more