actionmailbox 6.0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +51 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +13 -0
  5. data/app/controllers/action_mailbox/base_controller.rb +34 -0
  6. data/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb +103 -0
  7. data/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb +82 -0
  8. data/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb +62 -0
  9. data/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb +65 -0
  10. data/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb +54 -0
  11. data/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb +35 -0
  12. data/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb +19 -0
  13. data/app/controllers/rails/conductor/base_controller.rb +14 -0
  14. data/app/jobs/action_mailbox/incineration_job.rb +25 -0
  15. data/app/jobs/action_mailbox/routing_job.rb +13 -0
  16. data/app/models/action_mailbox/inbound_email.rb +49 -0
  17. data/app/models/action_mailbox/inbound_email/incineratable.rb +20 -0
  18. data/app/models/action_mailbox/inbound_email/incineratable/incineration.rb +26 -0
  19. data/app/models/action_mailbox/inbound_email/message_id.rb +38 -0
  20. data/app/models/action_mailbox/inbound_email/routable.rb +24 -0
  21. data/app/views/layouts/rails/conductor.html.erb +8 -0
  22. data/app/views/rails/conductor/action_mailbox/inbound_emails/index.html.erb +15 -0
  23. data/app/views/rails/conductor/action_mailbox/inbound_emails/new.html.erb +47 -0
  24. data/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb +15 -0
  25. data/config/routes.rb +19 -0
  26. data/db/migrate/20180917164000_create_action_mailbox_tables.rb +13 -0
  27. data/lib/action_mailbox.rb +17 -0
  28. data/lib/action_mailbox/base.rb +118 -0
  29. data/lib/action_mailbox/callbacks.rb +34 -0
  30. data/lib/action_mailbox/engine.rb +33 -0
  31. data/lib/action_mailbox/gem_version.rb +17 -0
  32. data/lib/action_mailbox/mail_ext.rb +6 -0
  33. data/lib/action_mailbox/mail_ext/address_equality.rb +9 -0
  34. data/lib/action_mailbox/mail_ext/address_wrapping.rb +9 -0
  35. data/lib/action_mailbox/mail_ext/addresses.rb +29 -0
  36. data/lib/action_mailbox/mail_ext/from_source.rb +7 -0
  37. data/lib/action_mailbox/mail_ext/recipients.rb +9 -0
  38. data/lib/action_mailbox/relayer.rb +75 -0
  39. data/lib/action_mailbox/router.rb +42 -0
  40. data/lib/action_mailbox/router/route.rb +42 -0
  41. data/lib/action_mailbox/routing.rb +22 -0
  42. data/lib/action_mailbox/test_case.rb +12 -0
  43. data/lib/action_mailbox/test_helper.rb +48 -0
  44. data/lib/action_mailbox/version.rb +10 -0
  45. data/lib/rails/generators/installer.rb +10 -0
  46. data/lib/rails/generators/mailbox/USAGE +12 -0
  47. data/lib/rails/generators/mailbox/mailbox_generator.rb +32 -0
  48. data/lib/rails/generators/mailbox/templates/application_mailbox.rb.tt +3 -0
  49. data/lib/rails/generators/mailbox/templates/mailbox.rb.tt +4 -0
  50. data/lib/rails/generators/test_unit/mailbox_generator.rb +20 -0
  51. data/lib/rails/generators/test_unit/templates/mailbox_test.rb.tt +11 -0
  52. data/lib/tasks/ingress.rake +72 -0
  53. data/lib/tasks/install.rake +20 -0
  54. metadata +186 -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,35 @@
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
+ mail[:bcc]&.include_in_headers = true
25
+ params[:mail][:attachments].to_a.each do |attachment|
26
+ mail.add_file(filename: attachment.original_filename, content: attachment.read)
27
+ end
28
+ end
29
+ end
30
+
31
+ def create_inbound_email(mail)
32
+ ActionMailbox::InboundEmail.create_and_extract_message_id!(mail.to_s)
33
+ end
34
+ end
35
+ 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,25 @@
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
+ #
10
+ # You can disable incinerating processed emails by setting +config.action_mailbox.incinerate+ or
11
+ # +ActionMailbox.incinerate+ to +false+.
12
+ class IncinerationJob < ActiveJob::Base
13
+ queue_as { ActionMailbox.queues[:incineration] }
14
+
15
+ discard_on ActiveRecord::RecordNotFound
16
+
17
+ def self.schedule(inbound_email)
18
+ set(wait: ActionMailbox.incinerate_after).perform_later(inbound_email)
19
+ end
20
+
21
+ def perform(inbound_email)
22
+ inbound_email.incinerate
23
+ end
24
+ end
25
+ 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: -> { ActionMailbox.incinerate && 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,8 @@
1
+ <html>
2
+ <head>
3
+ <title>Rails Conductor: <%= yield :title %></title>
4
+ </head>
5
+ <body>
6
+ <%= yield %>
7
+ </body>
8
+ </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,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do
5
+ post "/mandrill/inbound_emails" => "mandrill/inbound_emails#create", as: :rails_mandrill_inbound_emails
6
+ post "/postmark/inbound_emails" => "postmark/inbound_emails#create", as: :rails_postmark_inbound_emails
7
+ post "/relay/inbound_emails" => "relay/inbound_emails#create", as: :rails_relay_inbound_emails
8
+ post "/sendgrid/inbound_emails" => "sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails
9
+
10
+ # Mailgun requires that a webhook's URL end in 'mime' for it to receive the raw contents of emails.
11
+ post "/mailgun/inbound_emails/mime" => "mailgun/inbound_emails#create", as: :rails_mailgun_inbound_emails
12
+ end
13
+
14
+ # TODO: Should these be mounted within the engine only?
15
+ scope "rails/conductor/action_mailbox/", module: "rails/conductor/action_mailbox" do
16
+ resources :inbound_emails, as: :rails_conductor_inbound_emails
17
+ post ":inbound_email_id/reroute" => "reroutes#create", as: :rails_conductor_inbound_email_reroute
18
+ end
19
+ end
@@ -0,0 +1,13 @@
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
+ t.timestamps
9
+
10
+ t.index [ :message_id, :message_checksum ], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true
11
+ end
12
+ end
13
+ end