loading
Generated 2020-08-25T22:50:07-04:00

All Files ( 91.94% covered at 4.04 hits/line )

37 files in total.
509 relevant lines, 468 lines covered and 41 lines missed. ( 91.94% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/controllers/action_mailbox/base_controller.rb 93.75 % 34 16 15 1 7.19
app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb 88.89 % 123 36 32 4 2.50
app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb 94.44 % 86 36 34 2 1.53
app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb 100.00 % 62 7 7 0 1.14
app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb 100.00 % 65 9 9 0 1.11
app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb 84.62 % 68 13 11 2 1.15
app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb 88.89 % 35 18 16 2 1.61
app/controllers/rails/conductor/base_controller.rb 100.00 % 14 7 7 0 1.29
app/jobs/action_mailbox/incineration_job.rb 100.00 % 25 8 8 0 8.00
app/jobs/action_mailbox/routing_job.rb 100.00 % 13 5 5 0 3.40
app/models/action_mailbox/inbound_email.rb 100.00 % 49 14 14 0 8.64
app/models/action_mailbox/inbound_email/incineratable.rb 100.00 % 20 8 8 0 11.00
app/models/action_mailbox/inbound_email/incineratable/incineration.rb 100.00 % 26 11 11 0 1.73
app/models/action_mailbox/inbound_email/message_id.rb 93.33 % 38 15 14 1 17.40
app/models/action_mailbox/inbound_email/routable.rb 100.00 % 24 8 8 0 2.50
config/routes.rb 100.00 % 25 13 13 0 1.00
lib/action_mailbox.rb 100.00 % 17 11 11 0 1.00
lib/action_mailbox/base.rb 100.00 % 118 33 33 0 6.42
lib/action_mailbox/callbacks.rb 100.00 % 34 17 17 0 1.94
lib/action_mailbox/engine.rb 100.00 % 33 21 21 0 1.00
lib/action_mailbox/gem_version.rb 100.00 % 17 9 9 0 1.00
lib/action_mailbox/mail_ext.rb 100.00 % 6 2 2 0 3.50
lib/action_mailbox/mail_ext/address_equality.rb 100.00 % 9 4 4 0 1.00
lib/action_mailbox/mail_ext/address_wrapping.rb 100.00 % 9 4 4 0 1.25
lib/action_mailbox/mail_ext/addresses.rb 100.00 % 29 14 14 0 1.43
lib/action_mailbox/mail_ext/from_source.rb 100.00 % 7 3 3 0 28.00
lib/action_mailbox/mail_ext/recipients.rb 100.00 % 9 4 4 0 4.75
lib/action_mailbox/relayer.rb 100.00 % 75 38 38 0 3.89
lib/action_mailbox/router.rb 100.00 % 42 20 20 0 7.05
lib/action_mailbox/router/route.rb 100.00 % 42 19 19 0 9.63
lib/action_mailbox/routing.rb 100.00 % 26 12 12 0 1.17
lib/action_mailbox/test_case.rb 0.00 % 12 8 0 8 0.00
lib/action_mailbox/test_helper.rb 88.24 % 96 17 15 2 7.47
lib/action_mailbox/version.rb 100.00 % 10 4 4 0 1.00
lib/generators/action_mailbox/install/install_generator.rb 0.00 % 28 19 0 19 0.00
lib/rails/generators/mailbox/mailbox_generator.rb 100.00 % 32 16 16 0 3.81
lib/rails/generators/test_unit/mailbox_generator.rb 100.00 % 20 10 10 0 2.00

app/controllers/action_mailbox/base_controller.rb

93.75% lines covered

16 relevant lines. 15 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # The base class for all Action Mailbox ingress controllers.
  4. 1 class BaseController < ActionController::Base
  5. 1 skip_forgery_protection if default_protect_from_forgery
  6. 1 before_action :ensure_configured
  7. 1 private
  8. 1 def ensure_configured
  9. 26 unless ActionMailbox.ingress == ingress_name
  10. head :not_found
  11. end
  12. end
  13. 1 def ingress_name
  14. 26 self.class.name.remove(/\AActionMailbox::Ingresses::/, /::InboundEmailsController\z/).underscore.to_sym
  15. end
  16. 1 def authenticate_by_password
  17. 15 if password.present?
  18. 9 http_basic_authenticate_or_request_with name: "actionmailbox", password: password, realm: "Action Mailbox"
  19. else
  20. 6 raise ArgumentError, "Missing required ingress credentials"
  21. end
  22. end
  23. 1 def password
  24. 24 Rails.application.credentials.dig(:action_mailbox, :ingress_password) || ENV["RAILS_INBOUND_EMAIL_PASSWORD"]
  25. end
  26. end
  27. end

app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb

88.89% lines covered

36 relevant lines. 32 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Ingests inbound emails from Mailgun. Requires the following parameters:
  4. #
  5. # - +body-mime+: The full RFC 822 message
  6. # - +timestamp+: The current time according to Mailgun as the number of seconds passed since the UNIX epoch
  7. # - +token+: A randomly-generated, 50-character string
  8. # - +signature+: A hexadecimal HMAC-SHA256 of the timestamp concatenated with the token, generated using the Mailgun Signing key
  9. #
  10. # Authenticates requests by validating their signatures.
  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, or if its timestamp is more than 2 minutes old
  16. # - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Mailgun
  17. # - <tt>422 Unprocessable Entity</tt> if the request is missing required parameters
  18. # - <tt>500 Server Error</tt> if the Mailgun Signing key is missing, or 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. Give Action Mailbox your Mailgun Signing key (which you can find under Settings -> Security & Users -> API security in Mailgun)
  24. # so it can authenticate requests to the Mailgun ingress.
  25. #
  26. # Use <tt>bin/rails credentials:edit</tt> to add your Signing key to your application's encrypted credentials under
  27. # +action_mailbox.mailgun_signing_key+, where Action Mailbox will automatically find it:
  28. #
  29. # action_mailbox:
  30. # mailgun_signing_key: ...
  31. #
  32. # Alternatively, provide your Signing key in the +MAILGUN_INGRESS_SIGNING_KEY+ environment variable.
  33. #
  34. # 2. Tell Action Mailbox to accept emails from Mailgun:
  35. #
  36. # # config/environments/production.rb
  37. # config.action_mailbox.ingress = :mailgun
  38. #
  39. # 3. {Configure Mailgun}[https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages]
  40. # to forward inbound emails to +/rails/action_mailbox/mailgun/inbound_emails/mime+.
  41. #
  42. # If your application lived at <tt>https://example.com</tt>, you would specify the fully-qualified URL
  43. # <tt>https://example.com/rails/action_mailbox/mailgun/inbound_emails/mime</tt>.
  44. 1 class Ingresses::Mailgun::InboundEmailsController < ActionMailbox::BaseController
  45. 1 before_action :authenticate
  46. 1 def create
  47. 2 ActionMailbox::InboundEmail.create_and_extract_message_id! mail
  48. end
  49. 1 private
  50. 1 def mail
  51. 2 params.require("body-mime").tap do |raw_email|
  52. 2 raw_email.prepend("X-Original-To: ", params.require(:recipient), "\n") if params.key?(:recipient)
  53. end
  54. end
  55. 1 def authenticate
  56. 6 head :unauthorized unless authenticated?
  57. end
  58. 1 def authenticated?
  59. 6 if key.present?
  60. Authenticator.new(
  61. key: key,
  62. timestamp: params.require(:timestamp),
  63. token: params.require(:token),
  64. signature: params.require(:signature)
  65. 4 ).authenticated?
  66. else
  67. 2 raise ArgumentError, <<~MESSAGE.squish
  68. Missing required Mailgun Signing key. Set action_mailbox.mailgun_signing_key in your application's
  69. encrypted credentials or provide the MAILGUN_INGRESS_SIGNING_KEY environment variable.
  70. MESSAGE
  71. end
  72. end
  73. 1 def key
  74. 10 if Rails.application.credentials.dig(:action_mailbox, :mailgun_api_key)
  75. ActiveSupport::Deprecation.warn(<<-MSG.squish)
  76. Rails.application.credentials.action_mailbox.api_key is deprecated and will be ignored in Rails 6.2.
  77. Use Rails.application.credentials.action_mailbox.signing_key instead.
  78. MSG
  79. Rails.application.credentials.dig(:action_mailbox, :mailgun_api_key)
  80. 10 elsif ENV["MAILGUN_INGRESS_API_KEY"]
  81. ActiveSupport::Deprecation.warn(<<-MSG.squish)
  82. The MAILGUN_INGRESS_API_KEY environment variable is deprecated and will be ignored in Rails 6.2.
  83. Use MAILGUN_INGRESS_SIGNING_KEY instead.
  84. MSG
  85. ENV["MAILGUN_INGRESS_API_KEY"]
  86. else
  87. 10 Rails.application.credentials.dig(:action_mailbox, :mailgun_signing_key) || ENV["MAILGUN_INGRESS_SIGNING_KEY"]
  88. end
  89. end
  90. 1 class Authenticator
  91. 1 attr_reader :key, :timestamp, :token, :signature
  92. 1 def initialize(key:, timestamp:, token:, signature:)
  93. 4 @key, @timestamp, @token, @signature = key, Integer(timestamp), token, signature
  94. end
  95. 1 def authenticated?
  96. 4 signed? && recent?
  97. end
  98. 1 private
  99. 1 def signed?
  100. 4 ActiveSupport::SecurityUtils.secure_compare signature, expected_signature
  101. end
  102. # Allow for 2 minutes of drift between Mailgun time and local server time.
  103. 1 def recent?
  104. 3 Time.at(timestamp) >= 2.minutes.ago
  105. end
  106. 1 def expected_signature
  107. 4 OpenSSL::HMAC.hexdigest OpenSSL::Digest::SHA256.new, key, "#{timestamp}#{token}"
  108. end
  109. end
  110. end
  111. end

app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb

94.44% lines covered

36 relevant lines. 34 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Ingests inbound emails from Mandrill.
  4. #
  5. # Requires a +mandrill_events+ parameter containing a JSON array of Mandrill inbound email event objects.
  6. # Each event is expected to have a +msg+ object containing a full RFC 822 message in its +raw_msg+ property.
  7. #
  8. # Returns:
  9. #
  10. # - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
  11. # - <tt>401 Unauthorized</tt> if the request's signature could not be validated
  12. # - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Mandrill
  13. # - <tt>422 Unprocessable Entity</tt> if the request is missing required parameters
  14. # - <tt>500 Server Error</tt> if the Mandrill API key is missing, or one of the Active Record database,
  15. # the Active Storage service, or the Active Job backend is misconfigured or unavailable
  16. 1 class Ingresses::Mandrill::InboundEmailsController < ActionMailbox::BaseController
  17. 1 before_action :authenticate, except: :health_check
  18. 1 def create
  19. 2 raw_emails.each { |raw_email| ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email }
  20. 1 head :ok
  21. rescue JSON::ParserError => error
  22. logger.error error.message
  23. head :unprocessable_entity
  24. end
  25. 1 def health_check
  26. 1 head :ok
  27. end
  28. 1 private
  29. 1 def raw_emails
  30. 3 events.select { |event| event["event"] == "inbound" }.collect { |event| event.dig("msg", "raw_msg") }
  31. end
  32. 1 def events
  33. 1 JSON.parse params.require(:mandrill_events)
  34. end
  35. 1 def authenticate
  36. 4 head :unauthorized unless authenticated?
  37. end
  38. 1 def authenticated?
  39. 4 if key.present?
  40. 2 Authenticator.new(request, key).authenticated?
  41. else
  42. 2 raise ArgumentError, <<~MESSAGE.squish
  43. Missing required Mandrill API key. Set action_mailbox.mandrill_api_key in your application's
  44. encrypted credentials or provide the MANDRILL_INGRESS_API_KEY environment variable.
  45. MESSAGE
  46. end
  47. end
  48. 1 def key
  49. 6 Rails.application.credentials.dig(:action_mailbox, :mandrill_api_key) || ENV["MANDRILL_INGRESS_API_KEY"]
  50. end
  51. 1 class Authenticator
  52. 1 attr_reader :request, :key
  53. 1 def initialize(request, key)
  54. 2 @request, @key = request, key
  55. end
  56. 1 def authenticated?
  57. 2 ActiveSupport::SecurityUtils.secure_compare given_signature, expected_signature
  58. end
  59. 1 private
  60. 1 def given_signature
  61. 2 request.headers["X-Mandrill-Signature"]
  62. end
  63. 1 def expected_signature
  64. 2 Base64.strict_encode64 OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, key, message)
  65. end
  66. 1 def message
  67. 2 request.url + request.POST.sort.flatten.join
  68. end
  69. end
  70. end
  71. end

app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb

100.0% lines covered

7 relevant lines. 7 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Ingests inbound emails from Postmark. Requires a +RawEmail+ parameter containing a full RFC 822 message.
  4. #
  5. # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the
  6. # password is read from the application's encrypted credentials or an environment variable. See the Usage section below.
  7. #
  8. # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to
  9. # the Postmark ingress can learn its password. You should only use the Postmark ingress over HTTPS.
  10. #
  11. # Returns:
  12. #
  13. # - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
  14. # - <tt>401 Unauthorized</tt> if the request's signature could not be validated
  15. # - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Postmark
  16. # - <tt>422 Unprocessable Entity</tt> if the request is missing the required +RawEmail+ parameter
  17. # - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database,
  18. # the Active Storage service, or the Active Job backend is misconfigured or unavailable
  19. #
  20. # == Usage
  21. #
  22. # 1. Tell Action Mailbox to accept emails from Postmark:
  23. #
  24. # # config/environments/production.rb
  25. # config.action_mailbox.ingress = :postmark
  26. #
  27. # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the Postmark ingress.
  28. #
  29. # Use <tt>bin/rails credentials:edit</tt> to add the password to your application's encrypted credentials under
  30. # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it:
  31. #
  32. # action_mailbox:
  33. # ingress_password: ...
  34. #
  35. # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable.
  36. #
  37. # 3. {Configure Postmark}[https://postmarkapp.com/manual#configure-your-inbound-webhook-url] to forward inbound emails
  38. # to +/rails/action_mailbox/postmark/inbound_emails+ with the username +actionmailbox+ and the password you
  39. # previously generated. If your application lived at <tt>https://example.com</tt>, you would configure your
  40. # Postmark inbound webhook with the following fully-qualified URL:
  41. #
  42. # https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/postmark/inbound_emails
  43. #
  44. # *NOTE:* When configuring your Postmark inbound webhook, be sure to check the box labeled *"Include raw email
  45. # content in JSON payload"*. Action Mailbox needs the raw email content to work.
  46. 1 class Ingresses::Postmark::InboundEmailsController < ActionMailbox::BaseController
  47. 1 before_action :authenticate_by_password
  48. 1 def create
  49. 2 ActionMailbox::InboundEmail.create_and_extract_message_id! params.require("RawEmail")
  50. rescue ActionController::ParameterMissing => error
  51. 1 logger.error <<~MESSAGE
  52. #{error.message}
  53. When configuring your Postmark inbound webhook, be sure to check the box
  54. labeled "Include raw email content in JSON payload".
  55. MESSAGE
  56. 1 head :unprocessable_entity
  57. end
  58. end
  59. end

app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Ingests inbound emails relayed from an SMTP server.
  4. #
  5. # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the
  6. # password is read from the application's encrypted credentials or an environment variable. See the Usage section below.
  7. #
  8. # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to
  9. # the ingress can learn its password. You should only use this ingress over HTTPS.
  10. #
  11. # Returns:
  12. #
  13. # - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
  14. # - <tt>401 Unauthorized</tt> if the request could not be authenticated
  15. # - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails relayed from an SMTP server
  16. # - <tt>415 Unsupported Media Type</tt> if the request does not contain an RFC 822 message
  17. # - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database,
  18. # the Active Storage service, or the Active Job backend is misconfigured or unavailable
  19. #
  20. # == Usage
  21. #
  22. # 1. Tell Action Mailbox to accept emails from an SMTP relay:
  23. #
  24. # # config/environments/production.rb
  25. # config.action_mailbox.ingress = :relay
  26. #
  27. # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the ingress.
  28. #
  29. # Use <tt>bin/rails credentials:edit</tt> to add the password to your application's encrypted credentials under
  30. # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it:
  31. #
  32. # action_mailbox:
  33. # ingress_password: ...
  34. #
  35. # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable.
  36. #
  37. # 3. Configure your SMTP server to pipe inbound emails to the appropriate ingress command, providing the +URL+ of the
  38. # relay ingress and the +INGRESS_PASSWORD+ you previously generated.
  39. #
  40. # If your application lives at <tt>https://example.com</tt>, you would configure the Postfix SMTP server to pipe
  41. # inbound emails to the following command:
  42. #
  43. # bin/rails action_mailbox:ingress:postfix URL=https://example.com/rails/action_mailbox/postfix/inbound_emails INGRESS_PASSWORD=...
  44. #
  45. # Built-in ingress commands are available for these popular SMTP servers:
  46. #
  47. # - Exim (<tt>bin/rails action_mailbox:ingress:exim)
  48. # - Postfix (<tt>bin/rails action_mailbox:ingress:postfix)
  49. # - Qmail (<tt>bin/rails action_mailbox:ingress:qmail)
  50. 1 class Ingresses::Relay::InboundEmailsController < ActionMailbox::BaseController
  51. 1 before_action :authenticate_by_password, :require_valid_rfc822_message
  52. 1 def create
  53. 1 ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read
  54. end
  55. 1 private
  56. 1 def require_valid_rfc822_message
  57. 2 unless request.content_type == "message/rfc822"
  58. 1 head :unsupported_media_type
  59. end
  60. end
  61. end
  62. end

app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb

84.62% lines covered

13 relevant lines. 11 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Ingests inbound emails from SendGrid. Requires an +email+ parameter containing a full RFC 822 message.
  4. #
  5. # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the
  6. # password is read from the application's encrypted credentials or an environment variable. See the Usage section below.
  7. #
  8. # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to
  9. # the SendGrid ingress can learn its password. You should only use the SendGrid ingress over HTTPS.
  10. #
  11. # Returns:
  12. #
  13. # - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox
  14. # - <tt>401 Unauthorized</tt> if the request's signature could not be validated
  15. # - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from SendGrid
  16. # - <tt>422 Unprocessable Entity</tt> if the request is missing the required +email+ parameter
  17. # - <tt>500 Server Error</tt> if the ingress password is not configured, or if one of the Active Record database,
  18. # the Active Storage service, or the Active Job backend is misconfigured or unavailable
  19. #
  20. # == Usage
  21. #
  22. # 1. Tell Action Mailbox to accept emails from SendGrid:
  23. #
  24. # # config/environments/production.rb
  25. # config.action_mailbox.ingress = :sendgrid
  26. #
  27. # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the SendGrid ingress.
  28. #
  29. # Use <tt>bin/rails credentials:edit</tt> to add the password to your application's encrypted credentials under
  30. # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it:
  31. #
  32. # action_mailbox:
  33. # ingress_password: ...
  34. #
  35. # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable.
  36. #
  37. # 3. {Configure SendGrid Inbound Parse}[https://sendgrid.com/docs/for-developers/parsing-email/setting-up-the-inbound-parse-webhook/]
  38. # to forward inbound emails to +/rails/action_mailbox/sendgrid/inbound_emails+ with the username +actionmailbox+ and
  39. # the password you previously generated. If your application lived at <tt>https://example.com</tt>, you would
  40. # configure SendGrid with the following fully-qualified URL:
  41. #
  42. # https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/sendgrid/inbound_emails
  43. #
  44. # *NOTE:* When configuring your SendGrid Inbound Parse webhook, be sure to check the box labeled *"Post the raw,
  45. # full MIME message."* Action Mailbox needs the raw MIME message to work.
  46. 1 class Ingresses::Sendgrid::InboundEmailsController < ActionMailbox::BaseController
  47. 1 before_action :authenticate_by_password
  48. 1 def create
  49. 2 ActionMailbox::InboundEmail.create_and_extract_message_id! mail
  50. rescue JSON::ParserError => error
  51. logger.error error.message
  52. head :unprocessable_entity
  53. end
  54. 1 private
  55. 1 def mail
  56. 2 params.require(:email).tap do |raw_email|
  57. 3 envelope["to"].each { |to| raw_email.prepend("X-Original-To: ", to, "\n") } if params.key?(:envelope)
  58. end
  59. end
  60. 1 def envelope
  61. 1 JSON.parse(params.require(:envelope))
  62. end
  63. end
  64. end

app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb

88.89% lines covered

18 relevant lines. 16 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Rails
  3. 1 class Conductor::ActionMailbox::InboundEmailsController < Rails::Conductor::BaseController
  4. 1 def index
  5. @inbound_emails = ActionMailbox::InboundEmail.order(created_at: :desc)
  6. end
  7. 1 def new
  8. end
  9. 1 def show
  10. @inbound_email = ActionMailbox::InboundEmail.find(params[:id])
  11. end
  12. 1 def create
  13. 3 inbound_email = create_inbound_email(new_mail)
  14. 3 redirect_to main_app.rails_conductor_inbound_email_url(inbound_email)
  15. end
  16. 1 private
  17. 1 def new_mail
  18. 3 Mail.new(params.require(:mail).permit(:from, :to, :cc, :bcc, :x_original_to, :in_reply_to, :subject, :body).to_h).tap do |mail|
  19. 3 mail[:bcc]&.include_in_headers = true
  20. 3 params[:mail][:attachments].to_a.each do |attachment|
  21. 2 mail.add_file(filename: attachment.original_filename, content: attachment.read)
  22. end
  23. end
  24. end
  25. 1 def create_inbound_email(mail)
  26. 3 ActionMailbox::InboundEmail.create_and_extract_message_id!(mail.to_s)
  27. end
  28. end
  29. end

app/controllers/rails/conductor/base_controller.rb

100.0% lines covered

7 relevant lines. 7 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Rails
  3. # TODO: Move this to Rails::Conductor gem
  4. 1 class Conductor::BaseController < ActionController::Base
  5. 1 layout "rails/conductor"
  6. 1 before_action :ensure_development_env
  7. 1 private
  8. 1 def ensure_development_env
  9. 3 head :forbidden unless Rails.env.development?
  10. end
  11. end
  12. end

app/jobs/action_mailbox/incineration_job.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # You can configure when this +IncinerationJob+ will be run as a time-after-processing using the
  4. # +config.action_mailbox.incinerate_after+ or +ActionMailbox.incinerate_after+ setting.
  5. #
  6. # Since this incineration is set for the future, it'll automatically ignore any <tt>InboundEmail</tt>s
  7. # that have already been deleted and discard itself if so.
  8. #
  9. # You can disable incinerating processed emails by setting +config.action_mailbox.incinerate+ or
  10. # +ActionMailbox.incinerate+ to +false+.
  11. 1 class IncinerationJob < ActiveJob::Base
  12. 29 queue_as { ActionMailbox.queues[:incineration] }
  13. 1 discard_on ActiveRecord::RecordNotFound
  14. 1 def self.schedule(inbound_email)
  15. 27 set(wait: ActionMailbox.incinerate_after).perform_later(inbound_email)
  16. end
  17. 1 def perform(inbound_email)
  18. 3 inbound_email.incinerate
  19. end
  20. end
  21. end

app/jobs/action_mailbox/routing_job.rb

100.0% lines covered

5 relevant lines. 5 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Routing a new InboundEmail is an asynchronous operation, which allows the ingress controllers to quickly
  4. # accept new incoming emails without being burdened to hang while they're actually being processed.
  5. 1 class RoutingJob < ActiveJob::Base
  6. 13 queue_as { ActionMailbox.queues[:routing] }
  7. 1 def perform(inbound_email)
  8. 1 inbound_email.route
  9. end
  10. end
  11. end

app/models/action_mailbox/inbound_email.rb

100.0% lines covered

14 relevant lines. 14 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "mail"
  3. 1 module ActionMailbox
  4. # The +InboundEmail+ is an Active Record that keeps a reference to the raw email stored in Active Storage
  5. # and tracks the status of processing. By default, incoming emails will go through the following lifecycle:
  6. #
  7. # * Pending: Just received by one of the ingress controllers and scheduled for routing.
  8. # * Processing: During active processing, while a specific mailbox is running its #process method.
  9. # * Delivered: Successfully processed by the specific mailbox.
  10. # * Failed: An exception was raised during the specific mailbox's execution of the +#process+ method.
  11. # * Bounced: Rejected processing by the specific mailbox and bounced to sender.
  12. #
  13. # Once the +InboundEmail+ has reached the status of being either +delivered+, +failed+, or +bounced+,
  14. # it'll count as having been +#processed?+. Once processed, the +InboundEmail+ will be scheduled for
  15. # automatic incineration at a later point.
  16. #
  17. # When working with an +InboundEmail+, you'll usually interact with the parsed version of the source,
  18. # which is available as a +Mail+ object from +#mail+. But you can also access the raw source directly
  19. # using the +#source+ method.
  20. #
  21. # Examples:
  22. #
  23. # inbound_email.mail.from # => 'david@loudthinking.com'
  24. # inbound_email.source # Returns the full rfc822 source of the email as text
  25. 1 class InboundEmail < ActiveRecord::Base
  26. 1 self.table_name = "action_mailbox_inbound_emails"
  27. 1 include Incineratable, MessageId, Routable
  28. 1 has_one_attached :raw_email
  29. 1 enum status: %i[ pending processing delivered failed bounced ]
  30. 1 def mail
  31. 50 @mail ||= Mail.from_source(source)
  32. end
  33. 1 def source
  34. 29 @source ||= raw_email.download
  35. end
  36. 1 def processed?
  37. 31 delivered? || failed? || bounced?
  38. end
  39. end
  40. end
  41. 1 ActiveSupport.run_load_hooks :action_mailbox_inbound_email, ActionMailbox::InboundEmail

app/models/action_mailbox/inbound_email/incineratable.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # Ensure that the +InboundEmail+ is automatically scheduled for later incineration if the status has been
  3. # changed to +processed+. The later incineration will be invoked at the time specified by the
  4. # +ActionMailbox.incinerate_after+ time using the +IncinerationJob+.
  5. 1 module ActionMailbox::InboundEmail::Incineratable
  6. 1 extend ActiveSupport::Concern
  7. 1 included do
  8. 53 after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? }
  9. end
  10. 1 def incinerate_later
  11. 27 ActionMailbox::IncinerationJob.schedule self
  12. end
  13. 1 def incinerate
  14. 3 Incineration.new(self).run
  15. end
  16. end

app/models/action_mailbox/inbound_email/incineratable/incineration.rb

100.0% lines covered

11 relevant lines. 11 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Command class for carrying out the actual incineration of the +InboundMail+ that's been scheduled
  4. # for removal. Before the incineration – which really is just a call to +#destroy!+ – is run, we verify
  5. # that it's both eligible (by virtue of having already been processed) and time to do so (that is,
  6. # the +InboundEmail+ was processed after the +incinerate_after+ time).
  7. 1 class InboundEmail::Incineratable::Incineration
  8. 1 def initialize(inbound_email)
  9. 3 @inbound_email = inbound_email
  10. end
  11. 1 def run
  12. 3 @inbound_email.destroy! if due? && processed?
  13. end
  14. 1 private
  15. 1 def due?
  16. 3 @inbound_email.updated_at < ActionMailbox.incinerate_after.ago.end_of_day
  17. end
  18. 1 def processed?
  19. 3 @inbound_email.processed?
  20. end
  21. end
  22. end

app/models/action_mailbox/inbound_email/message_id.rb

93.33% lines covered

15 relevant lines. 14 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. # The +Message-ID+ as specified by rfc822 is supposed to be a unique identifier for that individual email.
  3. # That makes it an ideal tracking token for debugging and forensics, just like +X-Request-Id+ does for
  4. # web request.
  5. #
  6. # If an inbound email does not, against the rfc822 mandate, specify a Message-ID, one will be generated
  7. # using the approach from <tt>Mail::MessageIdField</tt>.
  8. 1 module ActionMailbox::InboundEmail::MessageId
  9. 1 extend ActiveSupport::Concern
  10. 1 class_methods do
  11. # Create a new +InboundEmail+ from the raw +source+ of the email, which is uploaded as an Active Storage
  12. # attachment called +raw_email+. Before the upload, extract the Message-ID from the +source+ and set
  13. # it as an attribute on the new +InboundEmail+.
  14. 1 def create_and_extract_message_id!(source, **options)
  15. 50 message_checksum = Digest::SHA1.hexdigest(source)
  16. 50 message_id = extract_message_id(source) || generate_missing_message_id(message_checksum)
  17. 50 create! options.merge(message_id: message_id, message_checksum: message_checksum) do |inbound_email|
  18. 50 inbound_email.raw_email.attach io: StringIO.new(source), filename: "message.eml", content_type: "message/rfc822"
  19. end
  20. rescue ActiveRecord::RecordNotUnique
  21. 2 nil
  22. end
  23. 1 private
  24. 51 def extract_message_id(source)
  25. Mail.from_source(source).message_id rescue nil
  26. end
  27. 1 def generate_missing_message_id(message_checksum)
  28. 1 Mail::MessageIdField.new("<#{message_checksum}@#{::Socket.gethostname}.mail>").message_id.tap do |message_id|
  29. 1 logger.warn "Message-ID couldn't be parsed or is missing. Generated a new Message-ID: #{message_id}"
  30. end
  31. end
  32. end
  33. end

app/models/action_mailbox/inbound_email/routable.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # A newly received +InboundEmail+ will not be routed synchronously as part of ingress controller's receival.
  3. # Instead, the routing will be done asynchronously, using a +RoutingJob+, to ensure maximum parallel capacity.
  4. #
  5. # By default, all newly created +InboundEmail+ records that have the status of +pending+, which is the default,
  6. # will be scheduled for automatic, deferred routing.
  7. 1 module ActionMailbox::InboundEmail::Routable
  8. 1 extend ActiveSupport::Concern
  9. 1 included do
  10. 1 after_create_commit :route_later, if: :pending?
  11. end
  12. # Enqueue a +RoutingJob+ for this +InboundEmail+.
  13. 1 def route_later
  14. 12 ActionMailbox::RoutingJob.perform_later self
  15. end
  16. # Route this +InboundEmail+ using the routing rules declared on the +ApplicationMailbox+.
  17. 1 def route
  18. 2 ApplicationMailbox.route self
  19. end
  20. end

config/routes.rb

100.0% lines covered

13 relevant lines. 13 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 Rails.application.routes.draw do
  3. 1 scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do
  4. 1 post "/postmark/inbound_emails" => "postmark/inbound_emails#create", as: :rails_postmark_inbound_emails
  5. 1 post "/relay/inbound_emails" => "relay/inbound_emails#create", as: :rails_relay_inbound_emails
  6. 1 post "/sendgrid/inbound_emails" => "sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails
  7. # Mandrill checks for the existence of a URL with a HEAD request before it will create the webhook.
  8. 1 get "/mandrill/inbound_emails" => "mandrill/inbound_emails#health_check", as: :rails_mandrill_inbound_health_check
  9. 1 post "/mandrill/inbound_emails" => "mandrill/inbound_emails#create", as: :rails_mandrill_inbound_emails
  10. # Mailgun requires that a webhook's URL end in 'mime' for it to receive the raw contents of emails.
  11. 1 post "/mailgun/inbound_emails/mime" => "mailgun/inbound_emails#create", as: :rails_mailgun_inbound_emails
  12. end
  13. # TODO: Should these be mounted within the engine only?
  14. 1 scope "rails/conductor/action_mailbox/", module: "rails/conductor/action_mailbox" do
  15. 1 resources :inbound_emails, as: :rails_conductor_inbound_emails
  16. 1 get "inbound_emails/sources/new", to: "inbound_emails/sources#new", as: :new_rails_conductor_inbound_email_source
  17. 1 post "inbound_emails/sources", to: "inbound_emails/sources#create", as: :rails_conductor_inbound_email_sources
  18. 1 post ":inbound_email_id/reroute" => "reroutes#create", as: :rails_conductor_inbound_email_reroute
  19. end
  20. end

lib/action_mailbox.rb

100.0% lines covered

11 relevant lines. 11 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "action_mailbox/mail_ext"
  3. 1 module ActionMailbox
  4. 1 extend ActiveSupport::Autoload
  5. 1 autoload :Base
  6. 1 autoload :Router
  7. 1 autoload :TestCase
  8. 1 mattr_accessor :ingress
  9. 1 mattr_accessor :logger
  10. 1 mattr_accessor :incinerate, default: true
  11. 1 mattr_accessor :incinerate_after, default: 30.days
  12. 1 mattr_accessor :queues, default: {}
  13. end

lib/action_mailbox/base.rb

100.0% lines covered

33 relevant lines. 33 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/rescuable"
  3. 1 require "action_mailbox/callbacks"
  4. 1 require "action_mailbox/routing"
  5. 1 module ActionMailbox
  6. # The base class for all application mailboxes. Not intended to be inherited from directly. Inherit from
  7. # +ApplicationMailbox+ instead, as that's where the app-specific routing is configured. This routing
  8. # is specified in the following ways:
  9. #
  10. # class ApplicationMailbox < ActionMailbox::Base
  11. # # Any of the recipients of the mail (whether to, cc, bcc) are matched against the regexp.
  12. # routing /^replies@/i => :replies
  13. #
  14. # # Any of the recipients of the mail (whether to, cc, bcc) needs to be an exact match for the string.
  15. # routing "help@example.com" => :help
  16. #
  17. # # Any callable (proc, lambda, etc) object is passed the inbound_email record and is a match if true.
  18. # routing ->(inbound_email) { inbound_email.mail.to.size > 2 } => :multiple_recipients
  19. #
  20. # # Any object responding to #match? is called with the inbound_email record as an argument. Match if true.
  21. # routing CustomAddress.new => :custom
  22. #
  23. # # Any inbound_email that has not been already matched will be sent to the BackstopMailbox.
  24. # routing :all => :backstop
  25. # end
  26. #
  27. # Application mailboxes need to overwrite the +#process+ method, which is invoked by the framework after
  28. # callbacks have been run. The callbacks available are: +before_processing+, +after_processing+, and
  29. # +around_processing+. The primary use case is ensure certain preconditions to processing are fulfilled
  30. # using +before_processing+ callbacks.
  31. #
  32. # If a precondition fails to be met, you can halt the processing using the +#bounced!+ method,
  33. # which will silently prevent any further processing, but not actually send out any bounce notice. You
  34. # can also pair this behavior with the invocation of an Action Mailer class responsible for sending out
  35. # an actual bounce email. This is done using the +#bounce_with+ method, which takes the mail object returned
  36. # by an Action Mailer method, like so:
  37. #
  38. # class ForwardsMailbox < ApplicationMailbox
  39. # before_processing :ensure_sender_is_a_user
  40. #
  41. # private
  42. # def ensure_sender_is_a_user
  43. # unless User.exist?(email_address: mail.from)
  44. # bounce_with UserRequiredMailer.missing(inbound_email)
  45. # end
  46. # end
  47. # end
  48. #
  49. # During the processing of the inbound email, the status will be tracked. Before processing begins,
  50. # the email will normally have the +pending+ status. Once processing begins, just before callbacks
  51. # and the +#process+ method is called, the status is changed to +processing+. If processing is allowed to
  52. # complete, the status is changed to +delivered+. If a bounce is triggered, then +bounced+. If an unhandled
  53. # exception is bubbled up, then +failed+.
  54. #
  55. # Exceptions can be handled at the class level using the familiar +Rescuable+ approach:
  56. #
  57. # class ForwardsMailbox < ApplicationMailbox
  58. # rescue_from(ApplicationSpecificVerificationError) { bounced! }
  59. # end
  60. 1 class Base
  61. 1 include ActiveSupport::Rescuable
  62. 1 include ActionMailbox::Callbacks, ActionMailbox::Routing
  63. 1 attr_reader :inbound_email
  64. 1 delegate :mail, :delivered!, :bounced!, to: :inbound_email
  65. 1 delegate :logger, to: ActionMailbox
  66. 1 def self.receive(inbound_email)
  67. 23 new(inbound_email).perform_processing
  68. end
  69. 1 def initialize(inbound_email)
  70. 23 @inbound_email = inbound_email
  71. end
  72. 1 def perform_processing #:nodoc:
  73. 23 track_status_of_inbound_email do
  74. 23 run_callbacks :process do
  75. 21 process
  76. end
  77. end
  78. rescue => exception
  79. # TODO: Include a reference to the inbound_email in the exception raised so error handling becomes easier
  80. 1 rescue_with_handler(exception) || raise
  81. end
  82. 1 def process
  83. # Overwrite in subclasses
  84. end
  85. 1 def finished_processing? #:nodoc:
  86. 5 inbound_email.delivered? || inbound_email.bounced?
  87. end
  88. # Enqueues the given +message+ for delivery and changes the inbound email's status to +:bounced+.
  89. 1 def bounce_with(message)
  90. 2 inbound_email.bounced!
  91. 2 message.deliver_later
  92. end
  93. 1 private
  94. 1 def track_status_of_inbound_email
  95. 23 inbound_email.processing!
  96. 23 yield
  97. 22 inbound_email.delivered! unless inbound_email.bounced?
  98. rescue
  99. 1 inbound_email.failed!
  100. 1 raise
  101. end
  102. end
  103. end
  104. 1 ActiveSupport.run_load_hooks :action_mailbox, ActionMailbox::Base

lib/action_mailbox/callbacks.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/callbacks"
  3. 1 module ActionMailbox
  4. # Defines the callbacks related to processing.
  5. 1 module Callbacks
  6. 1 extend ActiveSupport::Concern
  7. 1 include ActiveSupport::Callbacks
  8. 1 TERMINATOR = ->(mailbox, chain) do
  9. 5 chain.call
  10. 5 mailbox.finished_processing?
  11. end
  12. 1 included do
  13. 1 define_callbacks :process, terminator: TERMINATOR, skip_after_callbacks_if_terminated: true
  14. end
  15. 1 class_methods do
  16. 1 def before_processing(*methods, &block)
  17. 7 set_callback(:process, :before, *methods, &block)
  18. end
  19. 1 def after_processing(*methods, &block)
  20. 3 set_callback(:process, :after, *methods, &block)
  21. end
  22. 1 def around_processing(*methods, &block)
  23. 1 set_callback(:process, :around, *methods, &block)
  24. end
  25. end
  26. end
  27. end

lib/action_mailbox/engine.rb

100.0% lines covered

21 relevant lines. 21 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "rails"
  3. 1 require "action_controller/railtie"
  4. 1 require "active_job/railtie"
  5. 1 require "active_record/railtie"
  6. 1 require "active_storage/engine"
  7. 1 require "action_mailbox"
  8. 1 module ActionMailbox
  9. 1 class Engine < Rails::Engine
  10. 1 isolate_namespace ActionMailbox
  11. 1 config.eager_load_namespaces << ActionMailbox
  12. 1 config.action_mailbox = ActiveSupport::OrderedOptions.new
  13. 1 config.action_mailbox.incinerate = true
  14. 1 config.action_mailbox.incinerate_after = 30.days
  15. 1 config.action_mailbox.queues = ActiveSupport::InheritableOptions.new \
  16. incineration: :action_mailbox_incineration, routing: :action_mailbox_routing
  17. 1 initializer "action_mailbox.config" do
  18. 1 config.after_initialize do |app|
  19. 1 ActionMailbox.logger = app.config.action_mailbox.logger || Rails.logger
  20. 1 ActionMailbox.incinerate = app.config.action_mailbox.incinerate.nil? ? true : app.config.action_mailbox.incinerate
  21. 1 ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days
  22. 1 ActionMailbox.queues = app.config.action_mailbox.queues || {}
  23. 1 ActionMailbox.ingress = app.config.action_mailbox.ingress
  24. end
  25. end
  26. end
  27. end

lib/action_mailbox/gem_version.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Returns the currently-loaded version of Action Mailbox as a <tt>Gem::Version</tt>.
  4. 1 def self.gem_version
  5. 1 Gem::Version.new VERSION::STRING
  6. end
  7. 1 module VERSION
  8. 1 MAJOR = 6
  9. 1 MINOR = 1
  10. 1 TINY = 0
  11. 1 PRE = "alpha"
  12. 1 STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
  13. end
  14. end

lib/action_mailbox/mail_ext.rb

100.0% lines covered

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

lib/action_mailbox/mail_ext/address_equality.rb

100.0% lines covered

4 relevant lines. 4 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Mail
  3. 1 class Address
  4. 1 def ==(other_address)
  5. 1 other_address.is_a?(Mail::Address) && to_s == other_address.to_s
  6. end
  7. end
  8. end

lib/action_mailbox/mail_ext/address_wrapping.rb

100.0% lines covered

4 relevant lines. 4 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Mail
  3. 1 class Address
  4. 1 def self.wrap(address)
  5. 2 address.is_a?(Mail::Address) ? address : Mail::Address.new(address)
  6. end
  7. end
  8. end

lib/action_mailbox/mail_ext/addresses.rb

100.0% lines covered

14 relevant lines. 14 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Mail
  3. 1 class Message
  4. 1 def from_address
  5. 1 header[:from]&.address_list&.addresses&.first
  6. end
  7. 1 def recipients_addresses
  8. 1 to_addresses + cc_addresses + bcc_addresses + x_original_to_addresses
  9. end
  10. 1 def to_addresses
  11. 2 Array(header[:to]&.address_list&.addresses)
  12. end
  13. 1 def cc_addresses
  14. 2 Array(header[:cc]&.address_list&.addresses)
  15. end
  16. 1 def bcc_addresses
  17. 2 Array(header[:bcc]&.address_list&.addresses)
  18. end
  19. 1 def x_original_to_addresses
  20. 4 Array(header[:x_original_to]).collect { |header| Mail::Address.new header.to_s }
  21. end
  22. end
  23. end

lib/action_mailbox/mail_ext/from_source.rb

100.0% lines covered

3 relevant lines. 3 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Mail
  3. 1 def self.from_source(source)
  4. 82 Mail.new Mail::Utilities.binary_unsafe_to_crlf(source.to_s)
  5. end
  6. end

lib/action_mailbox/mail_ext/recipients.rb

100.0% lines covered

4 relevant lines. 4 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Mail
  3. 1 class Message
  4. 1 def recipients
  5. 16 Array(to) + Array(cc) + Array(bcc) + Array(header[:x_original_to]).map(&:to_s)
  6. end
  7. end
  8. end

lib/action_mailbox/relayer.rb

100.0% lines covered

38 relevant lines. 38 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "action_mailbox/version"
  3. 1 require "net/http"
  4. 1 require "uri"
  5. 1 module ActionMailbox
  6. 1 class Relayer
  7. 1 class Result < Struct.new(:status_code, :message)
  8. 1 def success?
  9. 8 !failure?
  10. end
  11. 1 def failure?
  12. 16 transient_failure? || permanent_failure?
  13. end
  14. 1 def transient_failure?
  15. 16 status_code.start_with?("4.")
  16. end
  17. 1 def permanent_failure?
  18. 2 status_code.start_with?("5.")
  19. end
  20. end
  21. 1 CONTENT_TYPE = "message/rfc822"
  22. 1 USER_AGENT = "Action Mailbox relayer v#{ActionMailbox.version}"
  23. 1 attr_reader :uri, :username, :password
  24. 1 def initialize(url:, username: "actionmailbox", password:)
  25. 8 @uri, @username, @password = URI(url), username, password
  26. end
  27. 1 def relay(source)
  28. 8 case response = post(source)
  29. when Net::HTTPSuccess
  30. 1 Result.new "2.0.0", "Successfully relayed message to ingress"
  31. when Net::HTTPUnauthorized
  32. 1 Result.new "4.7.0", "Invalid credentials for ingress"
  33. else
  34. 2 Result.new "4.0.0", "HTTP #{response.code}"
  35. end
  36. rescue IOError, SocketError, SystemCallError => error
  37. 2 Result.new "4.4.2", "Network error relaying to ingress: #{error.message}"
  38. rescue Timeout::Error
  39. 1 Result.new "4.4.2", "Timed out relaying to ingress"
  40. rescue => error
  41. 1 Result.new "4.0.0", "Error relaying to ingress: #{error.message}"
  42. end
  43. 1 private
  44. 1 def post(source)
  45. 8 client.post uri, source,
  46. "Content-Type" => CONTENT_TYPE,
  47. "User-Agent" => USER_AGENT,
  48. "Authorization" => "Basic #{Base64.strict_encode64(username + ":" + password)}"
  49. end
  50. 1 def client
  51. 8 @client ||= Net::HTTP.new(uri.host, uri.port).tap do |connection|
  52. 8 if uri.scheme == "https"
  53. 8 require "openssl"
  54. 8 connection.use_ssl = true
  55. 8 connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
  56. end
  57. 8 connection.open_timeout = 1
  58. 8 connection.read_timeout = 10
  59. end
  60. end
  61. end
  62. end

lib/action_mailbox/router.rb

100.0% lines covered

20 relevant lines. 20 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Encapsulates the routes that live on the ApplicationMailbox and performs the actual routing when
  4. # an inbound_email is received.
  5. 1 class Router
  6. 1 class RoutingError < StandardError; end
  7. 1 def initialize
  8. 16 @routes = []
  9. end
  10. 1 def add_routes(routes)
  11. 9 routes.each do |(address, mailbox_name)|
  12. 11 add_route address, to: mailbox_name
  13. end
  14. end
  15. 1 def add_route(address, to:)
  16. 18 routes.append Route.new(address, to: to)
  17. end
  18. 1 def route(inbound_email)
  19. 17 if mailbox = mailbox_for(inbound_email)
  20. 16 mailbox.receive(inbound_email)
  21. else
  22. 1 inbound_email.bounced!
  23. 1 raise RoutingError
  24. end
  25. end
  26. 1 def mailbox_for(inbound_email)
  27. 41 routes.detect { |route| route.match?(inbound_email) }&.mailbox_class
  28. end
  29. 1 private
  30. 1 attr_reader :routes
  31. end
  32. end
  33. 1 require "action_mailbox/router/route"

lib/action_mailbox/router/route.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # Encapsulates a route, which can then be matched against an inbound_email and provide a lookup of the matching
  4. # mailbox class. See examples for the different route addresses and how to use them in the +ActionMailbox::Base+
  5. # documentation.
  6. 1 class Router::Route
  7. 1 attr_reader :address, :mailbox_name
  8. 1 def initialize(address, to:)
  9. 18 @address, @mailbox_name = address, to
  10. 18 ensure_valid_address
  11. end
  12. 1 def match?(inbound_email)
  13. 21 case address
  14. when :all
  15. 2 true
  16. when String
  17. 30 inbound_email.mail.recipients.any? { |recipient| address.casecmp?(recipient) }
  18. when Regexp
  19. 2 inbound_email.mail.recipients.any? { |recipient| address.match?(recipient) }
  20. when Proc
  21. 1 address.call(inbound_email)
  22. else
  23. 3 address.match?(inbound_email)
  24. end
  25. end
  26. 1 def mailbox_class
  27. 18 "#{mailbox_name.to_s.camelize}Mailbox".constantize
  28. end
  29. 1 private
  30. 1 def ensure_valid_address
  31. 61 unless [ Symbol, String, Regexp, Proc ].any? { |klass| address.is_a?(klass) } || address.respond_to?(:match?)
  32. 1 raise ArgumentError, "Expected a Symbol, String, Regexp, Proc, or matchable, got #{address.inspect}"
  33. end
  34. end
  35. end
  36. end

lib/action_mailbox/routing.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionMailbox
  3. # See +ActionMailbox::Base+ for how to specify routing.
  4. 1 module Routing
  5. 1 extend ActiveSupport::Concern
  6. 1 included do
  7. 1 cattr_accessor :router, default: ActionMailbox::Router.new
  8. end
  9. 1 class_methods do
  10. 1 def routing(routes)
  11. 1 router.add_routes(routes)
  12. end
  13. 1 def route(inbound_email)
  14. 3 router.route(inbound_email)
  15. end
  16. 1 def mailbox_for(inbound_email)
  17. 1 router.mailbox_for(inbound_email)
  18. end
  19. end
  20. end
  21. end

lib/action_mailbox/test_case.rb

0.0% lines covered

8 relevant lines. 0 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. require "action_mailbox/test_helper"
  3. require "active_support/test_case"
  4. module ActionMailbox
  5. class TestCase < ActiveSupport::TestCase
  6. include ActionMailbox::TestHelper
  7. end
  8. end
  9. ActiveSupport.run_load_hooks :action_mailbox_test_case, ActionMailbox::TestCase

lib/action_mailbox/test_helper.rb

88.24% lines covered

17 relevant lines. 15 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "mail"
  3. 1 module ActionMailbox
  4. 1 module TestHelper
  5. # Create an +InboundEmail+ record using an eml fixture in the format of message/rfc822
  6. # referenced with +fixture_name+ located in +test/fixtures/files/fixture_name+.
  7. 1 def create_inbound_email_from_fixture(fixture_name, status: :processing)
  8. 14 create_inbound_email_from_source file_fixture(fixture_name).read, status: status
  9. end
  10. # Creates an +InboundEmail+ by specifying through options or a block.
  11. #
  12. # ==== Options
  13. #
  14. # * <tt>:status</tt> - The +status+ to set for the created +InboundEmail+.
  15. # For possible statuses, see {its documentation}[rdoc-ref:ActionMailbox::InboundEmail].
  16. #
  17. # ==== Creating a simple email
  18. #
  19. # When you only need to set basic fields like +from+, +to+, +subject+, and
  20. # +body+, you can pass them directly as options.
  21. #
  22. # create_inbound_email_from_mail(from: "david@loudthinking.com", subject: "Hello!")
  23. #
  24. # ==== Creating a multi-part email
  25. #
  26. # When you need to create a more intricate email, like a multi-part email
  27. # that contains both a plaintext version and an HTML version, you can pass a
  28. # block.
  29. #
  30. # create_inbound_email_from_mail do
  31. # to "David Heinemeier Hansson <david@loudthinking.com>"
  32. # from "Bilbo Baggins <bilbo@bagend.com>"
  33. # subject "Come down to the Shire!"
  34. #
  35. # text_part do
  36. # body "Please join us for a party at Bag End"
  37. # end
  38. #
  39. # html_part do
  40. # body "<h1>Please join us for a party at Bag End</h1>"
  41. # end
  42. # end
  43. #
  44. # As with +Mail.new+, you can also use a block parameter to define the parts
  45. # of the message:
  46. #
  47. # create_inbound_email_from_mail do |mail|
  48. # mail.to "David Heinemeier Hansson <david@loudthinking.com>"
  49. # mail.from "Bilbo Baggins <bilbo@bagend.com>"
  50. # mail.subject "Come down to the Shire!"
  51. #
  52. # mail.text_part do |part|
  53. # part.body "Please join us for a party at Bag End"
  54. # end
  55. #
  56. # mail.html_part do |part|
  57. # part.body "<h1>Please join us for a party at Bag End</h1>"
  58. # end
  59. # end
  60. 1 def create_inbound_email_from_mail(status: :processing, **mail_options, &block)
  61. 21 mail = Mail.new(mail_options, &block)
  62. # Bcc header is not encoded by default
  63. 21 mail[:bcc].include_in_headers = true if mail[:bcc]
  64. 21 create_inbound_email_from_source mail.to_s, status: status
  65. end
  66. # Create an +InboundEmail+ using the raw rfc822 +source+ as text.
  67. 1 def create_inbound_email_from_source(source, status: :processing)
  68. 40 ActionMailbox::InboundEmail.create_and_extract_message_id! source, status: status
  69. end
  70. # Create an +InboundEmail+ from fixture using the same arguments as +create_inbound_email_from_fixture+
  71. # and immediately route it to processing.
  72. 1 def receive_inbound_email_from_fixture(*args)
  73. 1 create_inbound_email_from_fixture(*args).tap(&:route)
  74. end
  75. # Create an +InboundEmail+ using the same options or block as
  76. # {create_inbound_email_from_mail}[rdoc-ref:#create_inbound_email_from_mail],
  77. # then immediately route it for processing.
  78. 1 def receive_inbound_email_from_mail(**kwargs, &block)
  79. create_inbound_email_from_mail(**kwargs, &block).tap(&:route)
  80. end
  81. # Create an +InboundEmail+ using the same arguments as +create_inbound_email_from_source+ and immediately route it
  82. # to processing.
  83. 1 def receive_inbound_email_from_source(*args)
  84. create_inbound_email_from_source(*args).tap(&:route)
  85. end
  86. end
  87. end

lib/action_mailbox/version.rb

100.0% lines covered

4 relevant lines. 4 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "gem_version"
  3. 1 module ActionMailbox
  4. # Returns the currently-loaded version of Action Mailbox as a <tt>Gem::Version</tt>.
  5. 1 def self.version
  6. 1 gem_version
  7. end
  8. end

lib/generators/action_mailbox/install/install_generator.rb

0.0% lines covered

19 relevant lines. 0 lines covered and 19 lines missed.
    
  1. # frozen_string_literal: true
  2. require "rails/generators/mailbox/mailbox_generator"
  3. module ActionMailbox
  4. module Generators
  5. class InstallGenerator < ::Rails::Generators::Base
  6. source_root Rails::Generators::MailboxGenerator.source_root
  7. def create_action_mailbox_files
  8. say "Copying application_mailbox.rb to app/mailboxes", :green
  9. template "application_mailbox.rb", "app/mailboxes/application_mailbox.rb"
  10. end
  11. def add_action_mailbox_production_environment_config
  12. environment <<~end_of_config, env: "production"
  13. # Prepare the ingress controller used to receive mail
  14. # config.action_mailbox.ingress = :relay
  15. end_of_config
  16. end
  17. def create_migrations
  18. rails_command "railties:install:migrations FROM=active_storage,action_mailbox", inline: true
  19. end
  20. end
  21. end
  22. end

lib/rails/generators/mailbox/mailbox_generator.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Rails
  3. 1 module Generators
  4. 1 class MailboxGenerator < NamedBase
  5. 1 source_root File.expand_path("templates", __dir__)
  6. 1 check_class_collision suffix: "Mailbox"
  7. 1 def create_mailbox_file
  8. 6 template "mailbox.rb", File.join("app/mailboxes", class_path, "#{file_name}_mailbox.rb")
  9. 6 in_root do
  10. 6 if behavior == :invoke && !File.exist?(application_mailbox_file_name)
  11. 5 template "application_mailbox.rb", application_mailbox_file_name
  12. end
  13. end
  14. end
  15. 1 hook_for :test_framework
  16. 1 private
  17. 1 def file_name # :doc:
  18. 18 @_file_name ||= super.sub(/_mailbox\z/i, "")
  19. end
  20. 1 def application_mailbox_file_name
  21. 10 "app/mailboxes/application_mailbox.rb"
  22. end
  23. end
  24. end
  25. end

lib/rails/generators/test_unit/mailbox_generator.rb

100.0% lines covered

10 relevant lines. 10 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module TestUnit
  3. 1 module Generators
  4. 1 class MailboxGenerator < ::Rails::Generators::NamedBase
  5. 1 source_root File.expand_path("templates", __dir__)
  6. 1 check_class_collision suffix: "MailboxTest"
  7. 1 def create_test_files
  8. 3 template "mailbox_test.rb", File.join("test/mailboxes", class_path, "#{file_name}_mailbox_test.rb")
  9. end
  10. 1 private
  11. 1 def file_name # :doc:
  12. 9 @_file_name ||= super.sub(/_mailbox\z/i, "")
  13. end
  14. end
  15. end
  16. end