actionmailbox 6.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +13 -0
  5. data/app/controllers/action_mailbox/base_controller.rb +38 -0
  6. data/app/controllers/action_mailbox/ingresses/amazon/inbound_emails_controller.rb +54 -0
  7. data/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +103 -0
  8. data/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +82 -0
  9. data/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb +62 -0
  10. data/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb +65 -0
  11. data/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb +54 -0
  12. data/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb +34 -0
  13. data/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb +19 -0
  14. data/app/controllers/rails/conductor/base_controller.rb +14 -0
  15. data/app/jobs/action_mailbox/incineration_job.rb +22 -0
  16. data/app/jobs/action_mailbox/routing_job.rb +13 -0
  17. data/app/models/action_mailbox/inbound_email.rb +49 -0
  18. data/app/models/action_mailbox/inbound_email/incineratable.rb +20 -0
  19. data/app/models/action_mailbox/inbound_email/incineratable/incineration.rb +26 -0
  20. data/app/models/action_mailbox/inbound_email/message_id.rb +38 -0
  21. data/app/models/action_mailbox/inbound_email/routable.rb +24 -0
  22. data/app/views/layouts/rails/conductor.html.erb +7 -0
  23. data/app/views/rails/conductor/action_mailbox/inbound_emails/index.html.erb +15 -0
  24. data/app/views/rails/conductor/action_mailbox/inbound_emails/new.html.erb +47 -0
  25. data/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb +15 -0
  26. data/config/routes.rb +20 -0
  27. data/db/migrate/20180917164000_create_action_mailbox_tables.rb +17 -0
  28. data/lib/action_mailbox.rb +16 -0
  29. data/lib/action_mailbox/base.rb +118 -0
  30. data/lib/action_mailbox/callbacks.rb +34 -0
  31. data/lib/action_mailbox/engine.rb +42 -0
  32. data/lib/action_mailbox/gem_version.rb +17 -0
  33. data/lib/action_mailbox/mail_ext.rb +6 -0
  34. data/lib/action_mailbox/mail_ext/address_equality.rb +9 -0
  35. data/lib/action_mailbox/mail_ext/address_wrapping.rb +9 -0
  36. data/lib/action_mailbox/mail_ext/addresses.rb +29 -0
  37. data/lib/action_mailbox/mail_ext/from_source.rb +7 -0
  38. data/lib/action_mailbox/mail_ext/recipients.rb +9 -0
  39. data/lib/action_mailbox/relayer.rb +75 -0
  40. data/lib/action_mailbox/router.rb +42 -0
  41. data/lib/action_mailbox/router/route.rb +42 -0
  42. data/lib/action_mailbox/routing.rb +22 -0
  43. data/lib/action_mailbox/test_case.rb +12 -0
  44. data/lib/action_mailbox/test_helper.rb +44 -0
  45. data/lib/action_mailbox/version.rb +10 -0
  46. data/lib/rails/generators/installer.rb +10 -0
  47. data/lib/rails/generators/mailbox/USAGE +12 -0
  48. data/lib/rails/generators/mailbox/mailbox_generator.rb +32 -0
  49. data/lib/rails/generators/mailbox/templates/application_mailbox.rb.tt +3 -0
  50. data/lib/rails/generators/mailbox/templates/mailbox.rb.tt +4 -0
  51. data/lib/rails/generators/test_unit/mailbox_generator.rb +20 -0
  52. data/lib/rails/generators/test_unit/templates/mailbox_test.rb.tt +13 -0
  53. data/lib/tasks/ingress.rake +72 -0
  54. data/lib/tasks/install.rake +20 -0
  55. metadata +184 -0
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMailbox
4
+ # Ingests inbound emails from SendGrid. Requires an +email+ parameter containing a full RFC 822 message.
5
+ #
6
+ # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the
7
+ # password is read from the application's encrypted credentials or an environment variable. See the Usage section below.
8
+ #
9
+ # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to
10
+ # the SendGrid ingress can learn its password. You should only use the SendGrid ingress over HTTPS.
11
+ #
12
+ # Returns:
13
+ #
14
+ # - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
15
+ # - <tt>401 Unauthorized</tt> if the request's signature could not be validated
16
+ # - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from SendGrid
17
+ # - <tt>422 Unprocessable Entity</tt> if the request is missing the required +email+ parameter
18
+ # - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database,
19
+ # the Active Storage service, or the Active Job backend is misconfigured or unavailable
20
+ #
21
+ # == Usage
22
+ #
23
+ # 1. Tell Action Mailbox to accept emails from SendGrid:
24
+ #
25
+ # # config/environments/production.rb
26
+ # config.action_mailbox.ingress = :sendgrid
27
+ #
28
+ # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the SendGrid ingress.
29
+ #
30
+ # Use <tt>rails credentials:edit</tt> to add the password to your application's encrypted credentials under
31
+ # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it:
32
+ #
33
+ # action_mailbox:
34
+ # ingress_password: ...
35
+ #
36
+ # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable.
37
+ #
38
+ # 3. {Configure SendGrid Inbound Parse}[https://sendgrid.com/docs/for-developers/parsing-email/setting-up-the-inbound-parse-webhook/]
39
+ # to forward inbound emails to +/rails/action_mailbox/sendgrid/inbound_emails+ with the username +actionmailbox+ and
40
+ # the password you previously generated. If your application lived at <tt>https://example.com</tt>, you would
41
+ # configure SendGrid with the following fully-qualified URL:
42
+ #
43
+ # https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/sendgrid/inbound_emails
44
+ #
45
+ # *NOTE:* When configuring your SendGrid Inbound Parse webhook, be sure to check the box labeled *"Post the raw,
46
+ # full MIME message."* Action Mailbox needs the raw MIME message to work.
47
+ class Ingresses::Sendgrid::InboundEmailsController < ActionMailbox::BaseController
48
+ before_action :authenticate_by_password
49
+
50
+ def create
51
+ ActionMailbox::InboundEmail.create_and_extract_message_id! params.require(:email)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ class Conductor::ActionMailbox::InboundEmailsController < Rails::Conductor::BaseController
5
+ def index
6
+ @inbound_emails = ActionMailbox::InboundEmail.order(created_at: :desc)
7
+ end
8
+
9
+ def new
10
+ end
11
+
12
+ def show
13
+ @inbound_email = ActionMailbox::InboundEmail.find(params[:id])
14
+ end
15
+
16
+ def create
17
+ inbound_email = create_inbound_email(new_mail)
18
+ redirect_to main_app.rails_conductor_inbound_email_url(inbound_email)
19
+ end
20
+
21
+ private
22
+ def new_mail
23
+ Mail.new(params.require(:mail).permit(:from, :to, :cc, :bcc, :in_reply_to, :subject, :body).to_h).tap do |mail|
24
+ params[:mail][:attachments].to_a.each do |attachment|
25
+ mail.attachments[attachment.original_filename] = { filename: attachment.path, content_type: attachment.content_type }
26
+ end
27
+ end
28
+ end
29
+
30
+ def create_inbound_email(mail)
31
+ ActionMailbox::InboundEmail.create_and_extract_message_id!(mail.to_s)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ # Rerouting will run routing and processing on an email that has already been, or attempted to be, processed.
5
+ class Conductor::ActionMailbox::ReroutesController < Rails::Conductor::BaseController
6
+ def create
7
+ inbound_email = ActionMailbox::InboundEmail.find(params[:inbound_email_id])
8
+ reroute inbound_email
9
+
10
+ redirect_to main_app.rails_conductor_inbound_email_url(inbound_email)
11
+ end
12
+
13
+ private
14
+ def reroute(inbound_email)
15
+ inbound_email.pending!
16
+ inbound_email.route_later
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ # TODO: Move this to Rails::Conductor gem
5
+ class Conductor::BaseController < ActionController::Base
6
+ layout "rails/conductor"
7
+ before_action :ensure_development_env
8
+
9
+ private
10
+ def ensure_development_env
11
+ head :forbidden unless Rails.env.development?
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMailbox
4
+ # You can configure when this +IncinerationJob+ will be run as a time-after-processing using the
5
+ # +config.action_mailbox.incinerate_after+ or +ActionMailbox.incinerate_after+ setting.
6
+ #
7
+ # Since this incineration is set for the future, it'll automatically ignore any <tt>InboundEmail</tt>s
8
+ # that have already been deleted and discard itself if so.
9
+ class IncinerationJob < ActiveJob::Base
10
+ queue_as { ActionMailbox.queues[:incineration] }
11
+
12
+ discard_on ActiveRecord::RecordNotFound
13
+
14
+ def self.schedule(inbound_email)
15
+ set(wait: ActionMailbox.incinerate_after).perform_later(inbound_email)
16
+ end
17
+
18
+ def perform(inbound_email)
19
+ inbound_email.incinerate
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMailbox
4
+ # Routing a new InboundEmail is an asynchronous operation, which allows the ingress controllers to quickly
5
+ # accept new incoming emails without being burdened to hang while they're actually being processed.
6
+ class RoutingJob < ActiveJob::Base
7
+ queue_as { ActionMailbox.queues[:routing] }
8
+
9
+ def perform(inbound_email)
10
+ inbound_email.route
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+
5
+ module ActionMailbox
6
+ # The +InboundEmail+ is an Active Record that keeps a reference to the raw email stored in Active Storage
7
+ # and tracks the status of processing. By default, incoming emails will go through the following lifecycle:
8
+ #
9
+ # * Pending: Just received by one of the ingress controllers and scheduled for routing.
10
+ # * Processing: During active processing, while a specific mailbox is running its #process method.
11
+ # * Delivered: Successfully processed by the specific mailbox.
12
+ # * Failed: An exception was raised during the specific mailbox's execution of the +#process+ method.
13
+ # * Bounced: Rejected processing by the specific mailbox and bounced to sender.
14
+ #
15
+ # Once the +InboundEmail+ has reached the status of being either +delivered+, +failed+, or +bounced+,
16
+ # it'll count as having been +#processed?+. Once processed, the +InboundEmail+ will be scheduled for
17
+ # automatic incineration at a later point.
18
+ #
19
+ # When working with an +InboundEmail+, you'll usually interact with the parsed version of the source,
20
+ # which is available as a +Mail+ object from +#mail+. But you can also access the raw source directly
21
+ # using the +#source+ method.
22
+ #
23
+ # Examples:
24
+ #
25
+ # inbound_email.mail.from # => 'david@loudthinking.com'
26
+ # inbound_email.source # Returns the full rfc822 source of the email as text
27
+ class InboundEmail < ActiveRecord::Base
28
+ self.table_name = "action_mailbox_inbound_emails"
29
+
30
+ include Incineratable, MessageId, Routable
31
+
32
+ has_one_attached :raw_email
33
+ enum status: %i[ pending processing delivered failed bounced ]
34
+
35
+ def mail
36
+ @mail ||= Mail.from_source(source)
37
+ end
38
+
39
+ def source
40
+ @source ||= raw_email.download
41
+ end
42
+
43
+ def processed?
44
+ delivered? || failed? || bounced?
45
+ end
46
+ end
47
+ end
48
+
49
+ ActiveSupport.run_load_hooks :action_mailbox_inbound_email, ActionMailbox::InboundEmail
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ensure that the +InboundEmail+ is automatically scheduled for later incineration if the status has been
4
+ # changed to +processed+. The later incineration will be invoked at the time specified by the
5
+ # +ActionMailbox.incinerate_after+ time using the +IncinerationJob+.
6
+ module ActionMailbox::InboundEmail::Incineratable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ after_update_commit :incinerate_later, if: -> { status_previously_changed? && processed? }
11
+ end
12
+
13
+ def incinerate_later
14
+ ActionMailbox::IncinerationJob.schedule self
15
+ end
16
+
17
+ def incinerate
18
+ Incineration.new(self).run
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMailbox
4
+ # Command class for carrying out the actual incineration of the +InboundMail+ that's been scheduled
5
+ # for removal. Before the incineration – which really is just a call to +#destroy!+ – is run, we verify
6
+ # that it's both eligible (by virtue of having already been processed) and time to do so (that is,
7
+ # the +InboundEmail+ was processed after the +incinerate_after+ time).
8
+ class InboundEmail::Incineratable::Incineration
9
+ def initialize(inbound_email)
10
+ @inbound_email = inbound_email
11
+ end
12
+
13
+ def run
14
+ @inbound_email.destroy! if due? && processed?
15
+ end
16
+
17
+ private
18
+ def due?
19
+ @inbound_email.updated_at < ActionMailbox.incinerate_after.ago.end_of_day
20
+ end
21
+
22
+ def processed?
23
+ @inbound_email.processed?
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The +Message-ID+ as specified by rfc822 is supposed to be a unique identifier for that individual email.
4
+ # That makes it an ideal tracking token for debugging and forensics, just like +X-Request-Id+ does for
5
+ # web request.
6
+ #
7
+ # If an inbound email does not, against the rfc822 mandate, specify a Message-ID, one will be generated
8
+ # using the approach from <tt>Mail::MessageIdField</tt>.
9
+ module ActionMailbox::InboundEmail::MessageId
10
+ extend ActiveSupport::Concern
11
+
12
+ class_methods do
13
+ # Create a new +InboundEmail+ from the raw +source+ of the email, which be uploaded as a Active Storage
14
+ # attachment called +raw_email+. Before the upload, extract the Message-ID from the +source+ and set
15
+ # it as an attribute on the new +InboundEmail+.
16
+ def create_and_extract_message_id!(source, **options)
17
+ message_checksum = Digest::SHA1.hexdigest(source)
18
+ message_id = extract_message_id(source) || generate_missing_message_id(message_checksum)
19
+
20
+ create! options.merge(message_id: message_id, message_checksum: message_checksum) do |inbound_email|
21
+ inbound_email.raw_email.attach io: StringIO.new(source), filename: "message.eml", content_type: "message/rfc822"
22
+ end
23
+ rescue ActiveRecord::RecordNotUnique
24
+ nil
25
+ end
26
+
27
+ private
28
+ def extract_message_id(source)
29
+ Mail.from_source(source).message_id rescue nil
30
+ end
31
+
32
+ def generate_missing_message_id(message_checksum)
33
+ Mail::MessageIdField.new("<#{message_checksum}@#{::Socket.gethostname}.mail>").message_id.tap do |message_id|
34
+ logger.warn "Message-ID couldn't be parsed or is missing. Generated a new Message-ID: #{message_id}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A newly received +InboundEmail+ will not be routed synchronously as part of ingress controller's receival.
4
+ # Instead, the routing will be done asynchronously, using a +RoutingJob+, to ensure maximum parallel capacity.
5
+ #
6
+ # By default, all newly created +InboundEmail+ records that have the status of +pending+, which is the default,
7
+ # will be scheduled for automatic, deferred routing.
8
+ module ActionMailbox::InboundEmail::Routable
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ after_create_commit :route_later, if: :pending?
13
+ end
14
+
15
+ # Enqueue a +RoutingJob+ for this +InboundEmail+.
16
+ def route_later
17
+ ActionMailbox::RoutingJob.perform_later self
18
+ end
19
+
20
+ # Route this +InboundEmail+ using the routing rules declared on the +ApplicationMailbox+.
21
+ def route
22
+ ApplicationMailbox.route self
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ <html>
2
+ <head>
3
+ <title>Rails Conductor: <%= yield :title %></title>
4
+ </head>
5
+ <body>
6
+ <%= yield %>
7
+ </html>
@@ -0,0 +1,15 @@
1
+ <% provide :title, "Deliver new inbound email" %>
2
+
3
+ <h1>All inbound emails</h1>
4
+
5
+ <table>
6
+ <tr><th>Message ID</th><th>Status</th></tr>
7
+ <% @inbound_emails.each do |inbound_email| %>
8
+ <tr>
9
+ <td><%= link_to inbound_email.message_id, main_app.rails_conductor_inbound_email_path(inbound_email) %></td>
10
+ <td><%= inbound_email.status %></td>
11
+ </tr>
12
+ <% end %>
13
+ </table>
14
+
15
+ <%= link_to "Deliver new inbound email", main_app.new_rails_conductor_inbound_email_path %>
@@ -0,0 +1,47 @@
1
+ <% provide :title, "Deliver new inbound email" %>
2
+
3
+ <h1>Deliver new inbound email</h1>
4
+
5
+ <%= form_with(url: main_app.rails_conductor_inbound_emails_path, scope: :mail, local: true) do |form| %>
6
+ <div>
7
+ <%= form.label :from, "From" %><br>
8
+ <%= form.text_field :from %>
9
+ </div>
10
+
11
+ <div>
12
+ <%= form.label :to, "To" %><br>
13
+ <%= form.text_field :to %>
14
+ </div>
15
+
16
+ <div>
17
+ <%= form.label :cc, "CC" %><br>
18
+ <%= form.text_field :cc %>
19
+ </div>
20
+
21
+ <div>
22
+ <%= form.label :bcc, "BCC" %><br>
23
+ <%= form.text_field :bcc %>
24
+ </div>
25
+
26
+ <div>
27
+ <%= form.label :in_reply_to, "In-Reply-To" %><br>
28
+ <%= form.text_field :in_reply_to %>
29
+ </div>
30
+
31
+ <div>
32
+ <%= form.label :subject, "Subject" %><br>
33
+ <%= form.text_field :subject %>
34
+ </div>
35
+
36
+ <div>
37
+ <%= form.label :body, "Body" %><br>
38
+ <%= form.text_area :body, size: "40x20" %>
39
+ </div>
40
+
41
+ <div>
42
+ <%= form.label :attachments, "Attachments" %><br>
43
+ <%= form.file_field :attachments, multiple: true %>
44
+ </div>
45
+
46
+ <%= form.submit "Deliver inbound email" %>
47
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <% provide :title, @inbound_email.message_id %>
2
+
3
+ <h1><%= @inbound_email.message_id %>: <%= @inbound_email.status %></h1>
4
+
5
+ <ul>
6
+ <li><%= button_to "Route again", main_app.rails_conductor_inbound_email_reroute_path(@inbound_email), method: :post %></li>
7
+ <li>Incinerate</li>
8
+ </ul>
9
+
10
+ <details>
11
+ <summary>Full email source</summary>
12
+ <pre><%= @inbound_email.source %></pre>
13
+ </details>
14
+
15
+ <%= link_to "Back to all inbound emails", main_app.rails_conductor_inbound_emails_path %>
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do
5
+ post "/amazon/inbound_emails" => "amazon/inbound_emails#create", as: :rails_amazon_inbound_emails
6
+ post "/mandrill/inbound_emails" => "mandrill/inbound_emails#create", as: :rails_mandrill_inbound_emails
7
+ post "/postmark/inbound_emails" => "postmark/inbound_emails#create", as: :rails_postmark_inbound_emails
8
+ post "/relay/inbound_emails" => "relay/inbound_emails#create", as: :rails_relay_inbound_emails
9
+ post "/sendgrid/inbound_emails" => "sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails
10
+
11
+ # Mailgun requires that a webhook's URL end in 'mime' for it to receive the raw contents of emails.
12
+ post "/mailgun/inbound_emails/mime" => "mailgun/inbound_emails#create", as: :rails_mailgun_inbound_emails
13
+ end
14
+
15
+ # TODO: Should these be mounted within the engine only?
16
+ scope "rails/conductor/action_mailbox/", module: "rails/conductor/action_mailbox" do
17
+ resources :inbound_emails, as: :rails_conductor_inbound_emails
18
+ post ":inbound_email_id/reroute" => "reroutes#create", as: :rails_conductor_inbound_email_reroute
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ class CreateActionMailboxTables < ActiveRecord::Migration[6.0]
2
+ def change
3
+ create_table :action_mailbox_inbound_emails do |t|
4
+ t.integer :status, default: 0, null: false
5
+ t.string :message_id, null: false
6
+ t.string :message_checksum, null: false
7
+
8
+ if supports_datetime_with_precision?
9
+ t.timestamps precision: 6
10
+ else
11
+ t.timestamps
12
+ end
13
+
14
+ t.index [ :message_id, :message_checksum ], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true
15
+ end
16
+ end
17
+ end