├── .ruby-version ├── .tool-versions ├── spec ├── fixtures │ ├── jwt_secret │ └── test_config.yml ├── lib │ ├── configuration_spec.rb │ ├── message_spec.rb │ ├── imap │ │ ├── message_spec.rb │ │ └── connection_spec.rb │ ├── crash_handler_spec.rb │ ├── delivery │ │ ├── letter_opener_spec.rb │ │ ├── que_spec.rb │ │ ├── logger_spec.rb │ │ ├── postback_spec.rb │ │ └── sidekiq_spec.rb │ ├── coordinator_spec.rb │ ├── mailbox_watcher_spec.rb │ ├── cli_spec.rb │ ├── jwt_spec.rb │ ├── logger │ │ └── structured_spec.rb │ ├── arbitration │ │ └── redis_spec.rb │ ├── mailbox_spec.rb │ └── microsoft_graph │ │ └── connection_spec.rb └── spec_helper.rb ├── logfile.log ├── bin └── mail_room ├── Gemfile ├── lib ├── mail_room │ ├── version.rb │ ├── microsoft_graph.rb │ ├── imap.rb │ ├── arbitration.rb │ ├── arbitration │ │ ├── noop.rb │ │ └── redis.rb │ ├── message.rb │ ├── imap │ │ ├── message.rb │ │ └── connection.rb │ ├── delivery │ │ ├── noop.rb │ │ ├── letter_opener.rb │ │ ├── logger.rb │ │ ├── que.rb │ │ ├── sidekiq.rb │ │ └── postback.rb │ ├── delivery.rb │ ├── connection.rb │ ├── crash_handler.rb │ ├── logger │ │ └── structured.rb │ ├── coordinator.rb │ ├── configuration.rb │ ├── jwt.rb │ ├── mailbox_watcher.rb │ ├── cli.rb │ ├── mailbox.rb │ └── microsoft_graph │ │ └── connection.rb └── mail_room.rb ├── Rakefile ├── .rubocop.yml ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitlab-ci.yml ├── LICENSE.txt ├── mail_room.gemspec ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md ├── .rubocop_todo.yml └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.4.1 2 | -------------------------------------------------------------------------------- /spec/fixtures/jwt_secret: -------------------------------------------------------------------------------- 1 | aGVsbG93b3JsZA== 2 | -------------------------------------------------------------------------------- /logfile.log: -------------------------------------------------------------------------------- 1 | # Logfile created on 2019-09-26 08:59:35 -0500 by logger.rb/66358 2 | -------------------------------------------------------------------------------- /bin/mail_room: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'mail_room' 4 | 5 | MailRoom::CLI.new(ARGV).start 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in mail_room.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/mail_room/version.rb: -------------------------------------------------------------------------------- 1 | module MailRoom 2 | # Current version of MailRoom gem 3 | VERSION = "0.11.1" 4 | end 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | Style/HashSyntax: 4 | Enabled: true 5 | EnforcedStyle: ruby19_no_mixed_keys 6 | EnforcedShorthandSyntax: either 7 | -------------------------------------------------------------------------------- /lib/mail_room/microsoft_graph.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MailRoom 4 | module MicrosoftGraph 5 | autoload :Connection, 'mail_room/microsoft_graph/connection' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/mail_room/imap.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MailRoom 4 | module IMAP 5 | autoload :Connection, 'mail_room/imap/connection' 6 | autoload :Message, 'mail_room/imap/message' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | log -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "bundler" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /lib/mail_room/arbitration.rb: -------------------------------------------------------------------------------- 1 | module MailRoom 2 | module Arbitration 3 | def [](name) 4 | require_relative("./arbitration/#{name}") 5 | 6 | case name 7 | when "redis" 8 | Arbitration::Redis 9 | else 10 | Arbitration::Noop 11 | end 12 | end 13 | 14 | module_function :[] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/mail_room/arbitration/noop.rb: -------------------------------------------------------------------------------- 1 | module MailRoom 2 | module Arbitration 3 | class Noop 4 | Options = Class.new do 5 | def initialize(*) 6 | super() 7 | end 8 | end 9 | 10 | def initialize(*) 11 | end 12 | 13 | def deliver?(*) 14 | true 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/mail_room/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MailRoom 4 | class Message 5 | attr_reader :uid, :body 6 | 7 | def initialize(uid:, body:) 8 | @uid = uid 9 | @body = body 10 | end 11 | 12 | def ==(other) 13 | self.class == other.class && uid == other.uid && body == other.body 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/mail_room/imap/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal:true 2 | 3 | module MailRoom 4 | module IMAP 5 | class Message < MailRoom::Message 6 | attr_reader :seqno 7 | 8 | def initialize(uid:, body:, seqno:) 9 | super(uid: uid, body: body) 10 | 11 | @seqno = seqno 12 | end 13 | 14 | def ==(other) 15 | super && seqno == other.seqno 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/fixtures/test_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :mailboxes: 3 | - 4 | :email: "user1@gmail.com" 5 | :password: "password" 6 | :name: "inbox" 7 | :delivery_url: "http://localhost:3000/inbox" 8 | :delivery_token: "abcdefg" 9 | :logger: 10 | :log_path: "logfile.log" 11 | - 12 | :email: "user2@gmail.com" 13 | :password: "password" 14 | :name: "inbox" 15 | :delivery_url: "http://localhost:3000/inbox" 16 | :delivery_token: "abcdefg" 17 | -------------------------------------------------------------------------------- /lib/mail_room.rb: -------------------------------------------------------------------------------- 1 | require 'net/imap' 2 | require 'optparse' 3 | require 'yaml' 4 | 5 | module MailRoom 6 | end 7 | 8 | require "mail_room/version" 9 | require "mail_room/configuration" 10 | require "mail_room/mailbox" 11 | require "mail_room/mailbox_watcher" 12 | require "mail_room/message" 13 | require "mail_room/connection" 14 | require "mail_room/coordinator" 15 | require "mail_room/cli" 16 | require 'mail_room/logger/structured' 17 | require 'mail_room/crash_handler' 18 | -------------------------------------------------------------------------------- /lib/mail_room/delivery/noop.rb: -------------------------------------------------------------------------------- 1 | module MailRoom 2 | module Delivery 3 | # Noop Delivery method 4 | # @author Tony Pitale 5 | class Noop 6 | Options = Class.new do 7 | def initialize(*) 8 | super() 9 | end 10 | end 11 | 12 | # build a new delivery, do nothing 13 | def initialize(*) 14 | end 15 | 16 | # accept the delivery, do nothing 17 | def deliver(*) 18 | true 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/mail_room/delivery.rb: -------------------------------------------------------------------------------- 1 | module MailRoom 2 | module Delivery 3 | def [](name) 4 | require_relative("./delivery/#{name}") 5 | 6 | case name 7 | when "postback" 8 | Delivery::Postback 9 | when "logger" 10 | Delivery::Logger 11 | when "letter_opener" 12 | Delivery::LetterOpener 13 | when "sidekiq" 14 | Delivery::Sidekiq 15 | when "que" 16 | Delivery::Que 17 | else 18 | Delivery::Noop 19 | end 20 | end 21 | 22 | module_function :[] 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/mail_room/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MailRoom 4 | class Connection 5 | attr_reader :mailbox, :new_message_handler 6 | 7 | def initialize(mailbox) 8 | @mailbox = mailbox 9 | @stopped = false 10 | end 11 | 12 | def on_new_message(&block) 13 | @new_message_handler = block 14 | end 15 | 16 | def stopped? 17 | @stopped 18 | end 19 | 20 | def wait 21 | raise NotImplementedError 22 | end 23 | 24 | def quit 25 | @stopped = true 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/mail_room/crash_handler.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | module MailRoom 4 | class CrashHandler 5 | SUPPORTED_FORMATS = %w[json none] 6 | 7 | def initialize(stream=STDOUT) 8 | @stream = stream 9 | end 10 | 11 | def handle(error, format) 12 | if format == 'json' 13 | @stream.puts json(error) 14 | return 15 | end 16 | 17 | raise error 18 | end 19 | 20 | private 21 | 22 | def json(error) 23 | { time: DateTime.now.iso8601(3), severity: :fatal, message: error.message, backtrace: error.backtrace }.to_json 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mail_room/logger/structured.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'logger' 3 | require 'json' 4 | 5 | module MailRoom 6 | module Logger 7 | class Structured < ::Logger 8 | 9 | def format_message(severity, timestamp, progname, message) 10 | raise ArgumentError.new("Message must be a Hash") unless message.is_a? Hash 11 | 12 | data = {} 13 | data[:severity] = severity 14 | data[:time] = format_timestamp(timestamp || Time.now) 15 | # only accept a Hash 16 | data.merge!(message) 17 | 18 | data.to_json + "\n" 19 | end 20 | 21 | private 22 | 23 | def format_timestamp(timestamp) 24 | case timestamp 25 | when Time 26 | timestamp.to_datetime.iso8601(3).to_s 27 | when DateTime 28 | timestamp.iso8601(3).to_s 29 | else 30 | timestamp 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # Cache gems in between builds 2 | 3 | services: 4 | - redis:latest 5 | 6 | .test-template: &test 7 | cache: 8 | paths: 9 | - vendor/ruby 10 | variables: 11 | REDIS_URL: redis://redis:6379 12 | 13 | script: 14 | - bundle exec rspec spec 15 | before_script: 16 | - apt update && apt install -y libicu-dev 17 | - ruby -v # Print out ruby version for debugging 18 | # Uncomment next line if your rails app needs a JS runtime: 19 | # - apt-get update -q && apt-get install nodejs -yqq 20 | - gem install bundler --no-document # Bundler is not installed with the image 21 | - bundle install -j $(nproc) --path vendor # Install dependencies into ./vendor/ruby 22 | 23 | rspec-2.5: 24 | image: "ruby:2.5" 25 | <<: *test 26 | 27 | rspec-2.6: 28 | image: "ruby:2.6" 29 | <<: *test 30 | 31 | rspec-2.7: 32 | image: "ruby:2.7" 33 | <<: *test 34 | -------------------------------------------------------------------------------- /spec/lib/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MailRoom::Configuration do 4 | let(:config_path) {File.expand_path('../fixtures/test_config.yml', File.dirname(__FILE__))} 5 | 6 | describe 'set_mailboxes' do 7 | context 'with config_path' do 8 | let(:configuration) { MailRoom::Configuration.new(config_path: config_path) } 9 | 10 | it 'parses yaml into mailbox objects' do 11 | MailRoom::Mailbox.stubs(:new).returns('mailbox1', 'mailbox2') 12 | 13 | expect(configuration.mailboxes).to eq(['mailbox1', 'mailbox2']) 14 | end 15 | end 16 | 17 | context 'without config_path' do 18 | let(:configuration) { MailRoom::Configuration.new } 19 | 20 | it 'sets mailboxes to an empty set' do 21 | MailRoom::Mailbox.stubs(:new) 22 | MailRoom::Mailbox.expects(:new).never 23 | 24 | expect(configuration.mailboxes).to eq([]) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/lib/message_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal:true 2 | 3 | require 'spec_helper' 4 | require 'securerandom' 5 | 6 | describe MailRoom::Message do 7 | let(:uid) { SecureRandom.hex } 8 | let(:body) { 'hello world' } 9 | 10 | subject { described_class.new(uid: uid, body: body) } 11 | 12 | describe '#initalize' do 13 | it 'initializes with required parameters' do 14 | subject 15 | 16 | expect(subject.uid).to eq(uid) 17 | expect(subject.body).to eq(body) 18 | end 19 | end 20 | 21 | describe '#==' do 22 | let(:dup) { described_class.new(uid: uid, body: body) } 23 | 24 | it 'matches an equivalent message' do 25 | expect(dup == subject).to be true 26 | end 27 | 28 | it 'does not match a message with a different UID' do 29 | msg = described_class.new(uid: '12345', body: body) 30 | 31 | expect(subject == msg).to be false 32 | expect(msg == subject).to be false 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/lib/imap/message_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal:true 2 | 3 | require 'spec_helper' 4 | require 'securerandom' 5 | 6 | describe MailRoom::IMAP::Message do 7 | let(:uid) { SecureRandom.hex } 8 | let(:body) { 'hello world' } 9 | let(:seqno) { 5 } 10 | 11 | subject { described_class.new(uid: uid, body: body, seqno: seqno) } 12 | 13 | describe '#initalize' do 14 | it 'initializes with required parameters' do 15 | subject 16 | 17 | expect(subject.uid).to eq(uid) 18 | expect(subject.body).to eq(body) 19 | expect(subject.seqno).to eq(seqno) 20 | end 21 | end 22 | 23 | describe '#==' do 24 | let(:dup) { described_class.new(uid: uid, body: body, seqno: seqno) } 25 | let(:base_msg) { MailRoom::Message.new(uid: uid, body: body) } 26 | 27 | it 'matches an equivalent message' do 28 | expect(dup == subject).to be true 29 | end 30 | 31 | it 'does not match a base message' do 32 | expect(subject == base_msg).to be false 33 | expect(base_msg == subject).to be false 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/mail_room/delivery/letter_opener.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | require 'mail' 3 | require 'letter_opener' 4 | 5 | module MailRoom 6 | module Delivery 7 | # LetterOpener Delivery method 8 | # @author Tony Pitale 9 | class LetterOpener 10 | Options = Struct.new(:location) do 11 | def initialize(mailbox) 12 | location = mailbox.location || mailbox.delivery_options[:location] 13 | 14 | super(location) 15 | end 16 | end 17 | 18 | # Build a new delivery, hold the delivery options 19 | # @param [MailRoom::Delivery::LetterOpener::Options] 20 | def initialize(delivery_options) 21 | @delivery_options = delivery_options 22 | end 23 | 24 | # Trigger `LetterOpener` to deliver our message 25 | # @param message [String] the email message as a string, RFC822 format 26 | def deliver(message) 27 | method = ::LetterOpener::DeliveryMethod.new(location: @delivery_options.location) 28 | method.deliver!(Mail.read_from_string(message)) 29 | 30 | true 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/mail_room/coordinator.rb: -------------------------------------------------------------------------------- 1 | module MailRoom 2 | # Coordinate the mailbox watchers 3 | # @author Tony Pitale 4 | class Coordinator 5 | attr_accessor :watchers, :running 6 | 7 | # build watchers for a set of mailboxes 8 | # @params mailboxes [Array] mailboxes to be watched 9 | def initialize(mailboxes) 10 | self.watchers = [] 11 | 12 | mailboxes.each {|box| self.watchers << MailboxWatcher.new(box)} 13 | end 14 | 15 | alias :running? :running 16 | 17 | # start each of the watchers to running 18 | def run 19 | watchers.each(&:run) 20 | 21 | self.running = true 22 | 23 | sleep_while_running 24 | ensure 25 | quit 26 | end 27 | 28 | # quit each of the watchers when we're done running 29 | def quit 30 | watchers.each(&:quit) 31 | end 32 | 33 | private 34 | # @private 35 | def sleep_while_running 36 | # do we need to sweep for dead watchers? 37 | # or do we let the mailbox rebuild connections 38 | while(running?) do; sleep 1; end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/mail_room/delivery/logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module MailRoom 4 | module Delivery 5 | # File/STDOUT Logger Delivery method 6 | # @author Tony Pitale 7 | class Logger 8 | Options = Struct.new(:log_path) do 9 | def initialize(mailbox) 10 | log_path = mailbox.log_path || mailbox.delivery_options[:log_path] 11 | 12 | super(log_path) 13 | end 14 | end 15 | 16 | # Build a new delivery, hold the delivery options 17 | # open a file or stdout for IO depending on the options 18 | # @param [MailRoom::Delivery::Logger::Options] 19 | def initialize(delivery_options) 20 | io = File.open(delivery_options.log_path, 'a') if delivery_options.log_path 21 | io ||= STDOUT 22 | 23 | io.sync = true 24 | 25 | @logger = ::Logger.new(io) 26 | end 27 | 28 | # Write the message to our logger 29 | # @param message [String] the email message as a string, RFC822 format 30 | def deliver(message) 31 | @logger.info message 32 | 33 | true 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/mail_room/configuration.rb: -------------------------------------------------------------------------------- 1 | require "erb" 2 | 3 | module MailRoom 4 | # Wraps configuration for a set of individual mailboxes with global config 5 | # @author Tony Pitale 6 | class Configuration 7 | attr_accessor :mailboxes, :log_path, :quiet 8 | 9 | # Initialize a new configuration of mailboxes 10 | def initialize(options={}) 11 | self.mailboxes = [] 12 | self.quiet = options.fetch(:quiet, false) 13 | 14 | if options.has_key?(:config_path) 15 | begin 16 | erb = ERB.new(File.read(options[:config_path])) 17 | erb.filename = options[:config_path] 18 | config_file = YAML.load(erb.result) 19 | 20 | set_mailboxes(config_file[:mailboxes]) 21 | rescue => e 22 | raise e unless quiet 23 | end 24 | end 25 | end 26 | 27 | # Builds individual mailboxes from YAML configuration 28 | # 29 | # @param mailboxes_config 30 | def set_mailboxes(mailboxes_config) 31 | mailboxes_config.each do |attributes| 32 | self.mailboxes << Mailbox.new(attributes) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/mail_room/jwt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday' 4 | require 'securerandom' 5 | require 'jwt' 6 | require 'base64' 7 | 8 | module MailRoom 9 | # Responsible for validating and generating JWT token 10 | class JWT 11 | DEFAULT_ISSUER = 'mailroom' 12 | DEFAULT_ALGORITHM = 'HS256' 13 | 14 | attr_reader :header, :secret_path, :issuer, :algorithm 15 | 16 | def initialize(header:, secret_path:, issuer:, algorithm:) 17 | @header = header 18 | @secret_path = secret_path 19 | @issuer = issuer || DEFAULT_ISSUER 20 | @algorithm = algorithm || DEFAULT_ALGORITHM 21 | end 22 | 23 | def valid? 24 | [@header, @secret_path, @issuer, @algorithm].none?(&:nil?) 25 | end 26 | 27 | def token 28 | return nil unless valid? 29 | 30 | secret = Base64.strict_decode64(File.read(@secret_path).chomp) 31 | payload = { 32 | nonce: SecureRandom.hex(12), 33 | iat: Time.now.to_i, # https://github.com/jwt/ruby-jwt#issued-at-claim 34 | iss: @issuer 35 | } 36 | ::JWT.encode payload, secret, @algorithm 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Tony Pitale 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start 3 | 4 | require 'bundler/setup' 5 | require 'date' 6 | 7 | require 'rspec' 8 | require 'mocha/api' 9 | 10 | require File.expand_path('../../lib/mail_room', __FILE__) 11 | 12 | RSpec.configure do |config| 13 | config.mock_with :mocha 14 | config.run_all_when_everything_filtered = true 15 | config.filter_run :focus 16 | 17 | # Run specs in random order to surface order dependencies. If you find an 18 | # order dependency and want to debug it, you can fix the order by providing 19 | # the seed, which is printed after each run. 20 | # --seed 1234 21 | config.order = 'random' 22 | end 23 | 24 | REQUIRED_MAILBOX_DEFAULTS = { 25 | name: "inbox", 26 | email: "user@example.com", 27 | password: "password123" 28 | } 29 | 30 | REQUIRED_MICROSOFT_GRAPH_DEFAULTS = { 31 | password: nil, 32 | inbox_method: :microsoft_graph, 33 | inbox_options: { 34 | tenant_id: '98776', 35 | client_id: '12345', 36 | client_secret: 'MY-SECRET', 37 | }.freeze 38 | }.freeze 39 | 40 | def build_mailbox(options = {}) 41 | MailRoom::Mailbox.new(REQUIRED_MAILBOX_DEFAULTS.merge(options)) 42 | end 43 | -------------------------------------------------------------------------------- /spec/lib/crash_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MailRoom::CrashHandler do 4 | 5 | let(:error_message) { "oh noes!" } 6 | let(:error) { RuntimeError.new(error_message) } 7 | let(:stdout) { StringIO.new } 8 | 9 | describe '#handle' do 10 | 11 | subject{ described_class.new(stdout).handle(error, format) } 12 | 13 | context 'when given a json format' do 14 | let(:format) { 'json' } 15 | 16 | it 'writes a json message to stdout' do 17 | subject 18 | stdout.rewind 19 | output = stdout.read 20 | 21 | expect(output).to end_with("\n") 22 | expect(JSON.parse(output)['message']).to eq(error_message) 23 | end 24 | end 25 | 26 | context 'when given a blank format' do 27 | let(:format) { "" } 28 | 29 | it 'raises an error as designed' do 30 | expect{ subject }.to raise_error(error.class, error_message) 31 | end 32 | end 33 | 34 | context 'when given a nonexistent format' do 35 | let(:format) { "nonsense" } 36 | 37 | it 'raises an error as designed' do 38 | expect{ subject }.to raise_error(error.class, error_message) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/lib/delivery/letter_opener_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mail_room/delivery/letter_opener' 3 | 4 | describe MailRoom::Delivery::LetterOpener do 5 | describe '#deliver' do 6 | let(:mailbox) {build_mailbox(location: '/tmp/somewhere')} 7 | let(:delivery_method) {stub} 8 | let(:mail) {stub} 9 | 10 | before :each do 11 | Mail.stubs(:read_from_string).returns(mail) 12 | ::LetterOpener::DeliveryMethod.stubs(:new).returns(delivery_method) 13 | delivery_method.stubs(:deliver!) 14 | end 15 | 16 | it 'creates a new LetterOpener::DeliveryMethod' do 17 | ::LetterOpener::DeliveryMethod.expects(:new).with(location: '/tmp/somewhere').returns(delivery_method) 18 | 19 | MailRoom::Delivery::LetterOpener.new(mailbox).deliver('a message') 20 | end 21 | 22 | it 'parses the message string with Mail' do 23 | ::Mail.expects(:read_from_string).with('a message') 24 | 25 | MailRoom::Delivery::LetterOpener.new(mailbox).deliver('a message') 26 | end 27 | 28 | it 'delivers the mail message' do 29 | delivery_method.expects(:deliver!).with(mail) 30 | 31 | MailRoom::Delivery::LetterOpener.new(mailbox).deliver('a message') 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /spec/lib/delivery/que_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mail_room/delivery/que' 3 | 4 | describe MailRoom::Delivery::Que do 5 | describe '#deliver' do 6 | let(:mailbox) {build_mailbox({ 7 | delivery_options: { 8 | database: 'delivery_test', 9 | username: 'postgres', 10 | password: '', 11 | queue: 'default', 12 | priority: 5, 13 | job_class: 'ParseMailJob' 14 | } 15 | })} 16 | 17 | let(:connection) {stub} 18 | let(:options) {MailRoom::Delivery::Que::Options.new(mailbox)} 19 | 20 | it 'stores the message in que_jobs table' do 21 | PG.expects(:connect).with({ 22 | host: 'localhost', 23 | port: 5432, 24 | dbname: 'delivery_test', 25 | user: 'postgres', 26 | password: '' 27 | }).returns(connection) 28 | 29 | connection.expects(:exec).with( 30 | "INSERT INTO que_jobs (priority, job_class, queue, args) VALUES ($1, $2, $3, $4)", 31 | [ 32 | 5, 33 | 'ParseMailJob', 34 | 'default', 35 | JSON.dump(['email']) 36 | ] 37 | ) 38 | 39 | MailRoom::Delivery::Que.new(options).deliver('email') 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/lib/delivery/logger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mail_room/delivery/logger' 3 | 4 | describe MailRoom::Delivery::Logger do 5 | describe '#initialize' do 6 | context "without a log path" do 7 | let(:mailbox) {build_mailbox} 8 | 9 | it 'creates a new ruby logger' do 10 | ::Logger.stubs(:new) 11 | 12 | ::Logger.expects(:new).with(STDOUT) 13 | 14 | MailRoom::Delivery::Logger.new(mailbox) 15 | end 16 | end 17 | 18 | context "with a log path" do 19 | let(:mailbox) {build_mailbox(log_path: '/var/log/mail-room.log')} 20 | 21 | it 'creates a new file to append to' do 22 | file = stub 23 | file.stubs(:sync=) 24 | 25 | File.expects(:open).with('/var/log/mail-room.log', 'a').returns(file) 26 | ::Logger.stubs(:new).with(file) 27 | 28 | MailRoom::Delivery::Logger.new(mailbox) 29 | end 30 | end 31 | end 32 | 33 | describe '#deliver' do 34 | let(:mailbox) {build_mailbox} 35 | 36 | it 'writes the message to info' do 37 | logger = stub(:info) 38 | ::Logger.stubs(:new).returns(logger) 39 | 40 | logger.expects(:info).with('a message') 41 | 42 | MailRoom::Delivery::Logger.new(mailbox).deliver('a message') 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | services: 10 | # Label used to access the service container 11 | redis: 12 | # Docker Hub image 13 | image: redis 14 | # Set health checks to wait until redis has started 15 | options: >- 16 | --health-cmd "redis-cli ping" 17 | --health-interval 10s 18 | --health-timeout 5s 19 | --health-retries 5 20 | ports: 21 | # Maps port 6379 on service container to the host 22 | - 6379:6379 23 | 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | ruby-version: 28 | - head 29 | - '3.2' 30 | - '3.1' 31 | - '3.0' 32 | - '2.7' 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Set up Ruby ${{ matrix.ruby-version }} 36 | uses: ruby/setup-ruby@v1 37 | with: 38 | ruby-version: ${{ matrix.ruby-version }} 39 | bundler-cache: true # 'bundle install' and cache 40 | - name: Run tests 41 | run: bundle exec rspec 42 | 43 | rubocop: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: Set up Ruby ${{ matrix.ruby-version }} 48 | uses: ruby/setup-ruby@v1 49 | with: 50 | ruby-version: ${{ matrix.ruby-version }} 51 | bundler-cache: true # 'bundle install' and cache 52 | - name: Run Rubocop 53 | run: bundle exec rubocop 54 | 55 | -------------------------------------------------------------------------------- /mail_room.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'mail_room/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "mail_room" 8 | gem.version = MailRoom::VERSION 9 | gem.authors = ["Tony Pitale"] 10 | gem.email = ["tpitale@gmail.com"] 11 | gem.description = %q{mail_room will proxy email (gmail) from IMAP to a delivery method} 12 | gem.summary = %q{mail_room will proxy email (gmail) from IMAP to a callback URL, logger, or letter_opener} 13 | gem.homepage = "http://github.com/tpitale/mail_room" 14 | 15 | gem.files = `git ls-files`.split($/) 16 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 17 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 18 | gem.require_paths = ["lib"] 19 | 20 | gem.add_dependency "net-imap", ">= 0.2.1" 21 | gem.add_dependency "oauth2", [">= 1.4.4", "< 3"] 22 | gem.add_dependency "jwt", ">= 2.0" 23 | 24 | gem.add_development_dependency "rake" 25 | gem.add_development_dependency "rspec", "~> 3.9" 26 | gem.add_development_dependency "rubocop", "~> 1.11" 27 | gem.add_development_dependency "mocha", "~> 2.0" 28 | gem.add_development_dependency "simplecov" 29 | 30 | # for testing delivery methods 31 | gem.add_development_dependency "faraday" 32 | gem.add_development_dependency "mail" 33 | gem.add_development_dependency "letter_opener" 34 | gem.add_development_dependency "redis", "~> 5" 35 | gem.add_development_dependency "redis-namespace" 36 | gem.add_development_dependency "pg" 37 | gem.add_development_dependency "charlock_holmes" 38 | gem.add_development_dependency "webmock" 39 | end 40 | -------------------------------------------------------------------------------- /lib/mail_room/mailbox_watcher.rb: -------------------------------------------------------------------------------- 1 | require "mail_room/connection" 2 | 3 | module MailRoom 4 | # TODO: split up between processing and idling? 5 | 6 | # Watch a Mailbox 7 | # @author Tony Pitale 8 | class MailboxWatcher 9 | attr_accessor :watching_thread 10 | 11 | # Watch a new mailbox 12 | # @param mailbox [MailRoom::Mailbox] the mailbox to watch 13 | def initialize(mailbox) 14 | @mailbox = mailbox 15 | 16 | @running = false 17 | @connection = nil 18 | end 19 | 20 | # are we running? 21 | # @return [Boolean] 22 | def running? 23 | @running 24 | end 25 | 26 | # run the mailbox watcher 27 | def run 28 | @mailbox.logger.info({ context: @mailbox.context, action: "Setting up watcher" }) 29 | @running = true 30 | 31 | connection.on_new_message do |message| 32 | @mailbox.deliver(message) 33 | end 34 | 35 | self.watching_thread = Thread.start do 36 | while(running?) do 37 | connection.wait 38 | end 39 | end 40 | 41 | watching_thread.abort_on_exception = true 42 | end 43 | 44 | # stop running, cleanup connection 45 | def quit 46 | @mailbox.logger.info({ context: @mailbox.context, action: "Quitting connection..." }) 47 | @running = false 48 | 49 | if @connection 50 | @connection.quit 51 | @connection = nil 52 | end 53 | 54 | if self.watching_thread 55 | self.watching_thread.join 56 | end 57 | end 58 | 59 | private 60 | 61 | def connection 62 | @connection ||= 63 | if @mailbox.microsoft_graph? 64 | ::MailRoom::MicrosoftGraph::Connection.new(@mailbox) 65 | else 66 | ::MailRoom::IMAP::Connection.new(@mailbox) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/lib/imap/connection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MailRoom::IMAP::Connection do 4 | let(:imap) {stub} 5 | let(:mailbox) {build_mailbox(delete_after_delivery: true, expunge_deleted: true)} 6 | 7 | before :each do 8 | Net::IMAP.stubs(:new).returns(imap) 9 | end 10 | 11 | context "with imap set up" do 12 | let(:connection) {MailRoom::IMAP::Connection.new(mailbox)} 13 | let(:uid) { 1 } 14 | let(:seqno) { 8 } 15 | 16 | before :each do 17 | imap.stubs(:starttls) 18 | imap.stubs(:login) 19 | imap.stubs(:select) 20 | end 21 | 22 | it "is logged in" do 23 | expect(connection.logged_in?).to eq(true) 24 | end 25 | 26 | it "is not idling" do 27 | expect(connection.idling?).to eq(false) 28 | end 29 | 30 | it "is not disconnected" do 31 | imap.stubs(:disconnected?).returns(false) 32 | 33 | expect(connection.disconnected?).to eq(false) 34 | end 35 | 36 | it "is ready to idle" do 37 | expect(connection.ready_to_idle?).to eq(true) 38 | end 39 | 40 | it "waits for a message to process" do 41 | new_message = MailRoom::IMAP::Message.new(uid: uid, body: 'a message', seqno: seqno) 42 | 43 | connection.on_new_message do |message| 44 | expect(message).to eq(new_message) 45 | true 46 | end 47 | 48 | attr = { 'UID' => uid, 'RFC822' => new_message.body } 49 | fetch_data = Net::IMAP::FetchData.new(seqno, attr) 50 | 51 | imap.expects(:idle) 52 | imap.stubs(:uid_search).with(mailbox.search_command).returns([], [uid]) 53 | imap.expects(:uid_fetch).with([uid], "RFC822").returns([fetch_data]) 54 | mailbox.expects(:deliver?).with(uid).returns(true) 55 | imap.expects(:store).with(seqno, "+FLAGS", [Net::IMAP::DELETED]) 56 | imap.expects(:expunge).once 57 | 58 | connection.wait 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/mail_room/cli.rb: -------------------------------------------------------------------------------- 1 | module MailRoom 2 | # The CLI parses ARGV into configuration to start the coordinator with. 3 | # @author Tony Pitale 4 | class CLI 5 | attr_accessor :configuration, :coordinator, :options 6 | 7 | # Initialize a new CLI instance to handle option parsing from arguments 8 | # into configuration to start the coordinator running on all mailboxes 9 | # 10 | # @param args [Array] `ARGV` passed from `bin/mail_room` 11 | def initialize(args) 12 | @options = {} 13 | 14 | OptionParser.new do |parser| 15 | parser.banner = [ 16 | "Usage: #{@name} [-c config_file]\n", 17 | " #{@name} --help\n" 18 | ].compact.join 19 | 20 | parser.on('-c', '--config FILE') do |path| 21 | options[:config_path] = path 22 | end 23 | 24 | parser.on('-q', '--quiet') do 25 | options[:quiet] = true 26 | end 27 | 28 | parser.on('--log-exit-as') do |format| 29 | options[:exit_error_format] = 'json' unless format.nil? 30 | end 31 | 32 | # parser.on("-l", "--log FILE") do |path| 33 | # options[:log_path] = path 34 | # end 35 | 36 | parser.on_tail("-?", "--help", "Display this usage information.") do 37 | puts "#{parser}\n" 38 | exit 39 | end 40 | end.parse!(args) 41 | 42 | self.configuration = Configuration.new(options) 43 | self.coordinator = Coordinator.new(configuration.mailboxes) 44 | end 45 | 46 | # Start the coordinator running, sets up signal traps 47 | def start 48 | Signal.trap(:INT) do 49 | coordinator.running = false 50 | end 51 | 52 | Signal.trap(:TERM) do 53 | exit 54 | end 55 | 56 | coordinator.run 57 | rescue Exception => e # not just Errors, but includes lower-level Exceptions 58 | CrashHandler.new.handle(e, @options[:exit_error_format]) 59 | exit 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/lib/coordinator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MailRoom::Coordinator do 4 | describe '#initialize' do 5 | it 'builds a watcher for each mailbox' do 6 | MailRoom::MailboxWatcher.expects(:new).with('mailbox1').returns('watcher1') 7 | MailRoom::MailboxWatcher.expects(:new).with('mailbox2').returns('watcher2') 8 | 9 | coordinator = MailRoom::Coordinator.new(['mailbox1', 'mailbox2']) 10 | 11 | expect(coordinator.watchers).to eq(['watcher1', 'watcher2']) 12 | end 13 | 14 | it 'makes no watchers when mailboxes is empty' do 15 | coordinator = MailRoom::Coordinator.new([]) 16 | expect(coordinator.watchers).to eq([]) 17 | end 18 | end 19 | 20 | describe '#run' do 21 | it 'runs each watcher' do 22 | watcher = stub 23 | watcher.stubs(:run) 24 | watcher.stubs(:quit) 25 | MailRoom::MailboxWatcher.stubs(:new).returns(watcher) 26 | coordinator = MailRoom::Coordinator.new(['mailbox1']) 27 | coordinator.stubs(:sleep_while_running) 28 | watcher.expects(:run) 29 | watcher.expects(:quit) 30 | 31 | coordinator.run 32 | end 33 | 34 | it 'should go to sleep after running watchers' do 35 | coordinator = MailRoom::Coordinator.new([]) 36 | coordinator.stubs(:running=) 37 | coordinator.stubs(:running?).returns(false) 38 | coordinator.expects(:running=).with(true) 39 | coordinator.expects(:running?) 40 | 41 | coordinator.run 42 | end 43 | 44 | it 'should set attribute running to true' do 45 | coordinator = MailRoom::Coordinator.new([]) 46 | coordinator.stubs(:sleep_while_running) 47 | coordinator.run 48 | 49 | expect(coordinator.running).to eq(true) 50 | end 51 | end 52 | 53 | describe '#quit' do 54 | it 'quits each watcher' do 55 | watcher = stub(:quit) 56 | MailRoom::MailboxWatcher.stubs(:new).returns(watcher) 57 | coordinator = MailRoom::Coordinator.new(['mailbox1']) 58 | watcher.expects(:quit) 59 | 60 | coordinator.quit 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/lib/mailbox_watcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MailRoom::MailboxWatcher do 4 | context 'with IMAP configured' do 5 | let(:mailbox) {build_mailbox} 6 | 7 | describe '#running?' do 8 | it 'is false by default' do 9 | watcher = MailRoom::MailboxWatcher.new(mailbox) 10 | expect(watcher.running?).to eq(false) 11 | end 12 | end 13 | 14 | describe '#run' do 15 | let(:imap) {stub(login: true, select: true)} 16 | let(:watcher) {MailRoom::MailboxWatcher.new(mailbox)} 17 | 18 | before :each do 19 | Net::IMAP.stubs(:new).returns(imap) # prevent connection 20 | end 21 | 22 | it 'loops over wait while running' do 23 | connection = MailRoom::IMAP::Connection.new(mailbox) 24 | 25 | MailRoom::IMAP::Connection.stubs(:new).returns(connection) 26 | 27 | watcher.expects(:running?).twice.returns(true, false) 28 | connection.expects(:wait).once 29 | connection.expects(:on_new_message).once 30 | 31 | watcher.run 32 | watcher.watching_thread.join # wait for finishing run 33 | end 34 | end 35 | 36 | describe '#quit' do 37 | let(:imap) {stub(login: true, select: true)} 38 | let(:watcher) {MailRoom::MailboxWatcher.new(mailbox)} 39 | 40 | before :each do 41 | Net::IMAP.stubs(:new).returns(imap) # prevent connection 42 | end 43 | 44 | it 'closes and waits for the connection' do 45 | connection = MailRoom::IMAP::Connection.new(mailbox) 46 | connection.stubs(:wait) 47 | connection.stubs(:quit) 48 | 49 | MailRoom::IMAP::Connection.stubs(:new).returns(connection) 50 | 51 | watcher.run 52 | 53 | expect(watcher.running?).to eq(true) 54 | 55 | connection.expects(:quit) 56 | 57 | watcher.quit 58 | 59 | expect(watcher.running?).to eq(false) 60 | end 61 | end 62 | end 63 | 64 | context 'with Microsoft Graph configured' do 65 | let(:mailbox) { build_mailbox(REQUIRED_MICROSOFT_GRAPH_DEFAULTS) } 66 | 67 | subject { described_class.new(mailbox) } 68 | 69 | it 'initializes a Microsoft Graph connection' do 70 | connection = stub(on_new_message: nil) 71 | 72 | MailRoom::MicrosoftGraph::Connection.stubs(:new).returns(connection) 73 | 74 | expect(subject.send(:connection)).to eq(connection) 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Other unethical or unprofessional conduct 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 17 | 18 | By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 19 | 20 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 21 | 22 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting a project maintainer at [INSERT EMAIL ADDRESS]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Maintainers are obligated to maintain confidentiality with regard to the reporter of an incident. 23 | 24 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.3.0, available at [http://contributor-covenant.org/version/1/3/0/](http://contributor-covenant.org/version/1/3/0/) 25 | -------------------------------------------------------------------------------- /spec/lib/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MailRoom::CLI do 4 | let(:config_path) {File.expand_path('../fixtures/test_config.yml', File.dirname(__FILE__))} 5 | let!(:configuration) {MailRoom::Configuration.new({config_path: config_path})} 6 | let(:coordinator) {stub(run: true, quit: true)} 7 | let(:configuration_args) { anything } 8 | let(:coordinator_args) { anything } 9 | 10 | describe '.new' do 11 | let(:args) {["-c", "a path"]} 12 | 13 | before :each do 14 | MailRoom::Configuration.expects(:new).with(configuration_args).returns(configuration) 15 | MailRoom::Coordinator.stubs(:new).with(coordinator_args).returns(coordinator) 16 | end 17 | 18 | context 'with configuration args' do 19 | let(:configuration_args) do 20 | {config_path: 'a path'} 21 | end 22 | 23 | it 'parses arguments into configuration' do 24 | expect(MailRoom::CLI.new(args).configuration).to eq configuration 25 | end 26 | end 27 | 28 | context 'with coordinator args' do 29 | let(:coordinator_args) do 30 | configuration.mailboxes 31 | end 32 | 33 | it 'creates a new coordinator with configuration' do 34 | expect(MailRoom::CLI.new(args).coordinator).to eq(coordinator) 35 | end 36 | end 37 | end 38 | 39 | describe '#start' do 40 | let(:cli) {MailRoom::CLI.new([])} 41 | 42 | before :each do 43 | cli.configuration = configuration 44 | cli.coordinator = coordinator 45 | cli.stubs(:exit) 46 | end 47 | 48 | it 'starts running the coordinator' do 49 | coordinator.expects(:run) 50 | 51 | cli.start 52 | end 53 | 54 | context 'on error' do 55 | let(:error) { RuntimeError.new("oh noes!") } 56 | let(:coordinator) { stub(run: true, quit: true) } 57 | let(:crash_handler) { stub(handle: nil) } 58 | 59 | before do 60 | cli.instance_variable_set(:@options, {exit_error_format: error_format}) 61 | coordinator.stubs(:run).raises(error) 62 | MailRoom::CrashHandler.stubs(:new).returns(crash_handler) 63 | end 64 | 65 | context 'json format provided' do 66 | let(:error_format) { 'json' } 67 | 68 | it 'passes onto CrashHandler' do 69 | crash_handler.expects(:handle).with(error, error_format) 70 | 71 | cli.start 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/lib/jwt_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'mail_room/jwt' 4 | 5 | describe MailRoom::JWT do 6 | let(:secret_path) { File.expand_path('../fixtures/jwt_secret', File.dirname(__FILE__)) } 7 | let(:secret) { Base64.strict_decode64(File.read(secret_path).chomp) } 8 | 9 | let(:standard_config) do 10 | { 11 | secret_path: secret_path, 12 | issuer: 'mailroom', 13 | header: 'Mailroom-Api-Request', 14 | algorithm: 'HS256' 15 | } 16 | end 17 | 18 | describe '#token' do 19 | let(:jwt) { described_class.new(**standard_config) } 20 | 21 | it 'generates a valid jwt token' do 22 | token = jwt.token 23 | expect(token).not_to be_empty 24 | 25 | payload = nil 26 | expect do 27 | payload = JWT.decode(token, secret, true, iss: 'mailroom', verify_iat: true, verify_iss: true, algorithm: 'HS256') 28 | end.not_to raise_error 29 | expect(payload).to be_an(Array) 30 | expect(payload).to match( 31 | [ 32 | a_hash_including( 33 | 'iss' => 'mailroom', 34 | 'nonce' => be_a(String), 35 | 'iat' => be_a(Integer) 36 | ), 37 | { 'alg' => 'HS256' } 38 | ] 39 | ) 40 | end 41 | 42 | it 'generates a different token for each invocation' do 43 | expect(jwt.token).not_to eql(jwt.token) 44 | end 45 | end 46 | 47 | describe '#valid?' do 48 | it 'returns true if all essential components are present' do 49 | jwt = described_class.new(**standard_config) 50 | expect(jwt.valid?).to eql(true) 51 | end 52 | 53 | it 'returns true if header and secret path are present' do 54 | jwt = described_class.new( 55 | secret_path: secret_path, 56 | header: 'Mailroom-Api-Request', 57 | issuer: nil, 58 | algorithm: nil 59 | ) 60 | expect(jwt.valid?).to eql(true) 61 | expect(jwt.issuer).to eql(described_class::DEFAULT_ISSUER) 62 | expect(jwt.algorithm).to eql(described_class::DEFAULT_ALGORITHM) 63 | end 64 | 65 | it 'returns false if either header or secret_path are missing' do 66 | expect(described_class.new( 67 | secret_path: nil, 68 | header: 'Mailroom-Api-Request', 69 | issuer: nil, 70 | algorithm: nil 71 | ).valid?).to eql(false) 72 | expect(described_class.new( 73 | secret_path: secret_path, 74 | header: nil, 75 | issuer: nil, 76 | algorithm: nil 77 | ).valid?).to eql(false) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/lib/logger/structured_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MailRoom::Logger::Structured do 4 | 5 | subject { described_class.new $stdout } 6 | 7 | let!(:now) { Time.now } 8 | let(:timestamp) { now.to_datetime.iso8601(3) } 9 | let(:message) { { action: 'exciting development', message: 'testing 123' } } 10 | 11 | before do 12 | Time.stubs(:now).returns(now) 13 | end 14 | 15 | [:debug, :info, :warn, :error, :fatal].each do |level| 16 | it "logs #{level}" do 17 | expect { subject.send(level, message) }.to output(json_matching(level.to_s.upcase, message)).to_stdout_from_any_process 18 | end 19 | end 20 | 21 | it 'logs unknown' do 22 | expect { subject.unknown(message) }.to output(json_matching("ANY", message)).to_stdout_from_any_process 23 | end 24 | 25 | it 'only accepts hashes' do 26 | expect { subject.unknown("just a string!") }.to raise_error(ArgumentError, /must be a Hash/) 27 | end 28 | 29 | context 'logging a hash as a message' do 30 | it 'merges the contents' do 31 | input = { 32 | additional_field: "some value" 33 | } 34 | expected = { 35 | severity: 'DEBUG', 36 | time: timestamp, 37 | additional_field: "some value" 38 | } 39 | 40 | expect { subject.debug(input) }.to output(as_regex(expected)).to_stdout_from_any_process 41 | end 42 | end 43 | 44 | describe '#format_message' do 45 | shared_examples 'timestamp formatting' do 46 | it 'outputs ISO8601 timestamps' do 47 | data = JSON.parse(subject.format_message('debug', input_timestamp, 'test', { message: 'hello' } )) 48 | 49 | expect(data['time']).to eq(expected_timestamp) 50 | end 51 | end 52 | 53 | context 'with no timestamp' do 54 | let(:input_timestamp) { nil } 55 | let(:expected_timestamp) { timestamp } 56 | 57 | it_behaves_like 'timestamp formatting' 58 | end 59 | 60 | context 'with DateTime' do 61 | let(:input_timestamp) { now.to_datetime } 62 | let(:expected_timestamp) { timestamp } 63 | 64 | it_behaves_like 'timestamp formatting' 65 | end 66 | 67 | context 'with string' do 68 | let(:input_timestamp) { now.to_s } 69 | let(:expected_timestamp) { input_timestamp } 70 | 71 | it_behaves_like 'timestamp formatting' 72 | end 73 | end 74 | 75 | def json_matching(level, message) 76 | contents = { 77 | severity: level, 78 | time: timestamp 79 | }.merge(message) 80 | 81 | as_regex(contents) 82 | end 83 | 84 | def as_regex(contents) 85 | /#{Regexp.quote(contents.to_json)}/ 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/mail_room/arbitration/redis.rb: -------------------------------------------------------------------------------- 1 | require "redis" 2 | 3 | module MailRoom 4 | module Arbitration 5 | class Redis 6 | Options = Struct.new(:redis_url, :namespace, :sentinels, :sentinel_username, :sentinel_password) do 7 | def initialize(mailbox) 8 | redis_url = mailbox.arbitration_options[:redis_url] || "redis://localhost:6379" 9 | namespace = mailbox.arbitration_options[:namespace] 10 | sentinels = mailbox.arbitration_options[:sentinels] 11 | sentinel_username = mailbox.arbitration_options[:sentinel_username] 12 | sentinel_password = mailbox.arbitration_options[:sentinel_password] 13 | 14 | if namespace 15 | warn <<~MSG 16 | Redis namespaces are deprecated. This option will be ignored in future versions. 17 | See https://github.com/sidekiq/sidekiq/issues/2586 for more details." 18 | MSG 19 | end 20 | 21 | super(redis_url, namespace, sentinels, sentinel_username, sentinel_password) 22 | end 23 | end 24 | 25 | # Expire after 10 minutes so Redis doesn't get filled up with outdated data. 26 | EXPIRATION = 600 27 | 28 | attr_accessor :options 29 | 30 | def initialize(options) 31 | @options = options 32 | end 33 | 34 | def deliver?(uid, expiration = EXPIRATION) 35 | key = "delivered:#{uid}" 36 | 37 | # Set the key, but only if it doesn't already exist; 38 | # the return value is true if successful, false if the key was already set, 39 | # which is conveniently the correct return value for this method 40 | # Any subsequent failure in the instance which gets the lock will be dealt 41 | # with by the expiration, at which time another instance can pick up the 42 | # message and try again. 43 | client.set(key, 1, nx: true, ex: expiration) 44 | end 45 | 46 | private 47 | 48 | def client 49 | @client ||= begin 50 | sentinels = options.sentinels 51 | redis_options = { url: options.redis_url } 52 | redis_options[:sentinels] = sentinels if sentinels 53 | redis_options[:sentinel_username] = options.sentinel_username if options.sentinel_username 54 | redis_options[:sentinel_password] = options.sentinel_password if options.sentinel_password 55 | 56 | redis = ::Redis.new(redis_options) 57 | 58 | namespace = options.namespace 59 | if namespace 60 | require 'redis/namespace' 61 | ::Redis::Namespace.new(namespace, redis: redis) 62 | else 63 | redis 64 | end 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/mail_room/delivery/que.rb: -------------------------------------------------------------------------------- 1 | require 'pg' 2 | require 'json' 3 | require 'charlock_holmes' 4 | 5 | module MailRoom 6 | module Delivery 7 | # Que Delivery method 8 | # @author Tony Pitale 9 | class Que 10 | Options = Struct.new(:host, :port, :database, :username, :password, :queue, :priority, :job_class, :logger) do 11 | def initialize(mailbox) 12 | host = mailbox.delivery_options[:host] || "localhost" 13 | port = mailbox.delivery_options[:port] || 5432 14 | database = mailbox.delivery_options[:database] 15 | username = mailbox.delivery_options[:username] 16 | password = mailbox.delivery_options[:password] 17 | 18 | queue = mailbox.delivery_options[:queue] || '' 19 | priority = mailbox.delivery_options[:priority] || 100 # lowest priority for Que 20 | job_class = mailbox.delivery_options[:job_class] 21 | logger = mailbox.logger 22 | 23 | super(host, port, database, username, password, queue, priority, job_class, logger) 24 | end 25 | end 26 | 27 | attr_reader :options 28 | 29 | # Build a new delivery, hold the mailbox configuration 30 | # @param [MailRoom::Delivery::Que::Options] 31 | def initialize(options) 32 | @options = options 33 | end 34 | 35 | # deliver the message by pushing it onto the configured Sidekiq queue 36 | # @param message [String] the email message as a string, RFC822 format 37 | def deliver(message) 38 | queue_job(utf8_encode_message(message)) 39 | @options.logger.info({ delivery_method: 'Que', action: 'message pushed' }) 40 | end 41 | 42 | private 43 | def connection 44 | PG.connect(connection_options) 45 | end 46 | 47 | def connection_options 48 | { 49 | host: options.host, 50 | port: options.port, 51 | dbname: options.database, 52 | user: options.username, 53 | password: options.password 54 | } 55 | end 56 | 57 | def queue_job(*args) 58 | sql = "INSERT INTO que_jobs (priority, job_class, queue, args) VALUES ($1, $2, $3, $4)" 59 | 60 | connection.exec(sql, [options.priority, options.job_class, options.queue, JSON.dump(args)]) 61 | end 62 | 63 | def utf8_encode_message(message) 64 | message = message.dup 65 | 66 | message.force_encoding("UTF-8") 67 | return message if message.valid_encoding? 68 | 69 | detection = CharlockHolmes::EncodingDetector.detect(message) 70 | return message unless detection && detection[:encoding] 71 | 72 | # Convert non-UTF-8 body UTF-8 so it can be dumped as JSON. 73 | CharlockHolmes::Converter.convert(message, detection[:encoding], 'UTF-8') 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/lib/delivery/postback_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mail_room/delivery/postback' 3 | require 'tempfile' 4 | require 'webmock/rspec' 5 | 6 | describe MailRoom::Delivery::Postback do 7 | describe '#deliver' do 8 | let(:delivery_options) do 9 | MailRoom::Delivery::Postback::Options.new(mailbox) 10 | end 11 | 12 | before do 13 | stub_request(:post, 'http://localhost/inbox') 14 | .with(body: 'a message', headers: headers) 15 | .to_return(status: 201) 16 | end 17 | 18 | shared_examples 'message poster' do 19 | it 'posts the message with faraday' do 20 | MailRoom::Delivery::Postback.new(delivery_options).deliver('a message') 21 | end 22 | end 23 | 24 | context 'with token auth delivery' do 25 | let(:mailbox) do 26 | build_mailbox({ 27 | delivery_url: 'http://localhost/inbox', 28 | delivery_token: 'abcdefg' 29 | }) 30 | end 31 | 32 | let(:headers) { { 'Authorization' => 'Token abcdefg' } } 33 | 34 | it_behaves_like 'message poster' 35 | end 36 | 37 | context 'with basic auth delivery options' do 38 | let(:mailbox) do 39 | build_mailbox({ 40 | delivery_options: { 41 | url: 'http://localhost/inbox', 42 | username: 'user1', 43 | password: 'password123abc' 44 | } 45 | }) 46 | end 47 | 48 | let(:headers) { { 'Authorization' => 'Basic dXNlcjE6cGFzc3dvcmQxMjNhYmM=' } } 49 | 50 | it_behaves_like 'message poster' 51 | 52 | context 'with content type in the delivery options' do 53 | let(:mailbox) do 54 | build_mailbox({ 55 | delivery_options: { 56 | url: 'http://localhost/inbox', 57 | username: 'user1', 58 | password: 'password123abc', 59 | content_type: 'text/plain' 60 | } 61 | }) 62 | end 63 | 64 | let(:headers) do 65 | { 66 | 'Authorization' => 'Basic dXNlcjE6cGFzc3dvcmQxMjNhYmM=', 67 | 'Content-Type' => 'text/plain' 68 | } 69 | end 70 | 71 | it_behaves_like 'message poster' 72 | end 73 | 74 | context 'with jwt token in the delivery options' do 75 | let(:mailbox) do 76 | build_mailbox({ 77 | delivery_options: { 78 | url: 'http://localhost/inbox', 79 | jwt_auth_header: "Mailroom-Api-Request", 80 | jwt_issuer: "mailroom", 81 | jwt_algorithm: "HS256", 82 | jwt_secret_path: jwt_secret.path, 83 | content_type: 'application/json' 84 | } 85 | }) 86 | end 87 | 88 | let(:headers) do 89 | { 90 | 'Content-Type' => 'application/json', 91 | 'Mailroom-Api-Request' => /.*/ 92 | } 93 | end 94 | 95 | let(:jwt_secret) do 96 | file = Tempfile.new('secret') 97 | file.write("test secret") 98 | file 99 | end 100 | 101 | after do 102 | jwt_secret.unlink 103 | end 104 | 105 | it_behaves_like 'message poster' 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## mail_room 0.11.1 ## 2 | 3 | https://github.com/tpitale/mail_room/compare/v0.11.0...v0.11.1 4 | 5 | ## mail_room 0.11.0 ## 6 | 7 | https://github.com/tpitale/mail_room/compare/v0.10.1...v0.11.0 8 | 9 | ## mail_room 0.10.1 ## 10 | 11 | * Fix db attribute on redis URL PR#130 - @jarkaK 12 | 13 | ## mail_room 0.10.0 ## 14 | 15 | * Remove imap backports 16 | * Increase minimum ruby version to 2.3 17 | * Postback basic_auth support - PR#92 18 | * Docs for ActionMailbox - PR#92 19 | * Configuration option for delivery_klass - PR#93 20 | * Expunge deleted - PR#90 21 | * Raise error on a few fields of missing configuration - PR#89 22 | * Remove fakeredis gem - PR#87 23 | 24 | *Tony Pitale <@tpitale>* 25 | 26 | * Fix redis arbitration to use NX+EX - PR#86 27 | 28 | *Craig Miskell <@craigmiskell-gitlab>* 29 | 30 | * Structured (JSON) logger - PR#88 31 | 32 | *charlie <@cablett>* 33 | 34 | ## mail_room 0.9.1 ## 35 | 36 | * __FILE__ support in yml ERb config - PR#80 37 | 38 | *Gabriel Mazetto <@brodock>* 39 | 40 | ## mail_room 0.9.0 ## 41 | 42 | * Redis Sentinel configuration support - PR#79 43 | 44 | *Gabriel Mazetto <@brodock>* 45 | 46 | ## mail_room 0.8.1 ## 47 | 48 | * Check watching thread exists before joining - PR#78 49 | 50 | *Michal Galet <@galet>* 51 | 52 | ## mail_room 0.8.0 ## 53 | 54 | * Rework the mailbox watcher and handler into a new Connection class to abstract away IMAP handling details 55 | 56 | *Tony Pitale <@tpitale>* 57 | 58 | ## mail_room 0.7.0 ## 59 | 60 | * Backports idle timeout from ruby 2.3.0 61 | * Sets default to 29 minutes to prevent IMAP disconnects 62 | * Validates that the timeout does not exceed 29 minutes 63 | 64 | *Tony Pitale <@tpitale>* 65 | 66 | ## mail_room 0.6.1 ## 67 | 68 | * ERB parsing of configuration yml file to enable using ENV variables 69 | 70 | *Douwe Maan <@DouweM>* 71 | 72 | ## mail_room 0.6.0 ## 73 | 74 | * Add redis Arbitration to reduce multiple deliveries of the same message when running multiple MailRoom instances on the same inbox 75 | 76 | *Douwe Maan <@DouweM>* 77 | 78 | ## mail_room 0.5.2 ## 79 | 80 | * Fix Sidekiq delivery method for non-UTF8 email 81 | 82 | *Douwe Maan <@DouweM>* 83 | 84 | * Add StartTLS session support 85 | 86 | *Tony Pitale <@tpitale>* 87 | 88 | ## mail_room 0.5.1 ## 89 | 90 | * Re-idle after 29 minutes to maintain IDLE connection 91 | 92 | *Douwe Maan <@DouweM>* 93 | 94 | ## mail_room 0.5.0 ## 95 | 96 | * Que delivery method 97 | 98 | *Tony Pitale <@tpitale>* 99 | 100 | ## mail_room 0.4.2 ## 101 | 102 | * rescue from all IOErrors, not just EOFError 103 | 104 | *Douwe Maan <@DouweM>* 105 | 106 | ## mail_room 0.4.1 ## 107 | 108 | * Fix redis default host/port configuration 109 | * Mailbox does not attempt delivery without a message 110 | 111 | *Douwe Maan <@DouweM>* 112 | 113 | ## mail_room 0.4.0 ## 114 | 115 | * Sidekiq delivery method 116 | * Option to delete messages after delivered 117 | 118 | *Douwe Maan <@DouweM>* 119 | 120 | * -q/--quiet do not raise errors on missing configuration 121 | * prefetch mail messages before idling 122 | * delivery-method-specific delivery options configuration 123 | 124 | *Tony Pitale <@tpitale>* 125 | 126 | ## mail_room 0.3.1 ## 127 | 128 | * Rescue from EOFError and re-setup mailroom 129 | 130 | *Tony Pitale <@tpitale>* 131 | 132 | ## mail_room 0.3.0 ## 133 | 134 | * Reconnect and idle if disconnected during an existing idle. 135 | * Set idling thread to abort on exception so any unhandled exceptions will stop mail_room running. 136 | 137 | *Tony Pitale <@tpitale>* 138 | -------------------------------------------------------------------------------- /lib/mail_room/delivery/sidekiq.rb: -------------------------------------------------------------------------------- 1 | require "redis" 2 | require "securerandom" 3 | require "json" 4 | require "charlock_holmes" 5 | 6 | module MailRoom 7 | module Delivery 8 | # Sidekiq Delivery method 9 | # @author Douwe Maan 10 | class Sidekiq 11 | Options = Struct.new(:redis_url, :namespace, :sentinels, :queue, :worker, :logger, :redis_db, :sentinel_username, :sentinel_password) do 12 | def initialize(mailbox) 13 | redis_url = mailbox.delivery_options[:redis_url] || "redis://localhost:6379" 14 | redis_db = mailbox.delivery_options[:redis_db] || 0 15 | namespace = mailbox.delivery_options[:namespace] 16 | sentinels = mailbox.delivery_options[:sentinels] 17 | sentinel_username = mailbox.delivery_options[:sentinel_username] 18 | sentinel_password = mailbox.delivery_options[:sentinel_password] 19 | queue = mailbox.delivery_options[:queue] || "default" 20 | worker = mailbox.delivery_options[:worker] 21 | logger = mailbox.logger 22 | 23 | if namespace 24 | warn <<~MSG 25 | Redis namespaces are deprecated. This option will be ignored in future versions. 26 | See https://github.com/sidekiq/sidekiq/issues/2586 for more details." 27 | MSG 28 | end 29 | 30 | super(redis_url, namespace, sentinels, queue, worker, logger, redis_db, sentinel_username, sentinel_password) 31 | end 32 | end 33 | 34 | attr_accessor :options 35 | 36 | # Build a new delivery, hold the mailbox configuration 37 | # @param [MailRoom::Delivery::Sidekiq::Options] 38 | def initialize(options) 39 | @options = options 40 | end 41 | 42 | # deliver the message by pushing it onto the configured Sidekiq queue 43 | # @param message [String] the email message as a string, RFC822 format 44 | def deliver(message) 45 | item = item_for(message) 46 | 47 | client.lpush("queue:#{options.queue}", JSON.generate(item)) 48 | 49 | @options.logger.info({ delivery_method: 'Sidekiq', action: 'message pushed' }) 50 | true 51 | end 52 | 53 | private 54 | 55 | def client 56 | @client ||= begin 57 | sentinels = options.sentinels 58 | redis_options = { url: options.redis_url, db: options.redis_db } 59 | redis_options[:sentinels] = sentinels if sentinels 60 | redis_options[:sentinel_username] = options.sentinel_username if options.sentinel_username 61 | redis_options[:sentinel_password] = options.sentinel_password if options.sentinel_password 62 | 63 | redis = ::Redis.new(redis_options) 64 | 65 | namespace = options.namespace 66 | if namespace 67 | require 'redis/namespace' 68 | Redis::Namespace.new(namespace, redis: redis) 69 | else 70 | redis 71 | end 72 | end 73 | end 74 | 75 | def item_for(message) 76 | { 77 | 'class' => options.worker, 78 | 'args' => [utf8_encode_message(message)], 79 | 'queue' => options.queue, 80 | 'jid' => SecureRandom.hex(12), 81 | 'retry' => false, 82 | 'enqueued_at' => Time.now.to_f 83 | } 84 | end 85 | 86 | def utf8_encode_message(message) 87 | message = message.dup 88 | 89 | message.force_encoding("UTF-8") 90 | return message if message.valid_encoding? 91 | 92 | detection = CharlockHolmes::EncodingDetector.detect(message) 93 | return message unless detection && detection[:encoding] 94 | 95 | # Convert non-UTF-8 body UTF-8 so it can be dumped as JSON. 96 | CharlockHolmes::Converter.convert(message, detection[:encoding], 'UTF-8') 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/mail_room/delivery/postback.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require "mail_room/jwt" 3 | 4 | module MailRoom 5 | module Delivery 6 | # Postback Delivery method 7 | # @author Tony Pitale 8 | class Postback 9 | Options = Struct.new(:url, :token, :username, :password, :logger, :content_type, :jwt) do 10 | def initialize(mailbox) 11 | url = 12 | mailbox.delivery_url || 13 | mailbox.delivery_options[:delivery_url] || 14 | mailbox.delivery_options[:url] 15 | 16 | token = 17 | mailbox.delivery_token || 18 | mailbox.delivery_options[:delivery_token] || 19 | mailbox.delivery_options[:token] 20 | 21 | jwt = initialize_jwt(mailbox.delivery_options) 22 | 23 | username = 24 | mailbox.delivery_options[:username] || 25 | mailbox.delivery_options[:delivery_username] 26 | password = 27 | mailbox.delivery_options[:password] || 28 | mailbox.delivery_options[:delivery_password] 29 | 30 | logger = mailbox.logger 31 | 32 | content_type = mailbox.delivery_options[:content_type] 33 | 34 | super(url, token, username, password, logger, content_type, jwt) 35 | end 36 | 37 | def token_auth? 38 | !self[:token].nil? 39 | end 40 | 41 | def jwt_auth? 42 | self[:jwt].valid? 43 | end 44 | 45 | def basic_auth? 46 | !self[:username].nil? && !self[:password].nil? 47 | end 48 | 49 | private 50 | 51 | def initialize_jwt(delivery_options) 52 | ::MailRoom::JWT.new( 53 | header: delivery_options[:jwt_auth_header], 54 | secret_path: delivery_options[:jwt_secret_path], 55 | algorithm: delivery_options[:jwt_algorithm], 56 | issuer: delivery_options[:jwt_issuer] 57 | ) 58 | end 59 | end 60 | 61 | # Build a new delivery, hold the delivery options 62 | # @param [MailRoom::Delivery::Postback::Options] 63 | def initialize(delivery_options) 64 | @delivery_options = delivery_options 65 | end 66 | 67 | # deliver the message using Faraday to the configured delivery_options url 68 | # @param message [String] the email message as a string, RFC822 format 69 | def deliver(message) 70 | connection = Faraday.new 71 | 72 | if @delivery_options.token_auth? 73 | config_token_auth(connection) 74 | elsif @delivery_options.basic_auth? 75 | config_basic_auth(connection) 76 | end 77 | 78 | result = connection.post(@delivery_options.url, message) do |request| 79 | config_request_content_type(request) 80 | config_request_jwt_auth(request) 81 | end 82 | 83 | @delivery_options.logger.info({ delivery_method: 'Postback', action: 'message pushed', url: @delivery_options.url, status_code: result.status }) 84 | true 85 | end 86 | 87 | private 88 | 89 | def config_request_content_type(request) 90 | return if @delivery_options.content_type.nil? 91 | 92 | request.headers['Content-Type'] = @delivery_options.content_type 93 | end 94 | 95 | def config_request_jwt_auth(request) 96 | return unless @delivery_options.jwt_auth? 97 | 98 | request.headers[@delivery_options.jwt.header] = @delivery_options.jwt.token 99 | end 100 | 101 | def config_token_auth(connection) 102 | # connection.token_auth was removed in Faraday v2 in favor of connection.request(:authorization, 'Token', token) 103 | if defined?(connection.token_auth) 104 | connection.token_auth @delivery_options.token 105 | else 106 | connection.request( 107 | :authorization, 'Token', 108 | @delivery_options.token 109 | ) 110 | end 111 | end 112 | 113 | def config_basic_auth(connection) 114 | if defined?(connection.basic_auth) 115 | connection.basic_auth( 116 | @delivery_options.username, 117 | @delivery_options.password 118 | ) 119 | else 120 | connection.request( 121 | :authorization, :basic, 122 | @delivery_options.username, 123 | @delivery_options.password 124 | ) 125 | end 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/lib/delivery/sidekiq_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mail_room/delivery/sidekiq' 3 | 4 | describe MailRoom::Delivery::Sidekiq do 5 | subject { described_class.new(options) } 6 | let(:redis) { subject.send(:client) } 7 | let(:raw_client) { redis._client } 8 | let(:options) { MailRoom::Delivery::Sidekiq::Options.new(mailbox) } 9 | 10 | describe '#options' do 11 | let(:redis_url) { 'redis://localhost:6379' } 12 | let(:redis_options) { { redis_url: redis_url } } 13 | 14 | context 'when only redis_url is specified' do 15 | let(:mailbox) { 16 | build_mailbox( 17 | delivery_method: :sidekiq, 18 | delivery_options: redis_options 19 | ) 20 | } 21 | 22 | context 'with simple redis url' do 23 | it 'client has same specified redis_url' do 24 | expect(raw_client.config.server_url).to eq(redis_url) 25 | end 26 | 27 | it 'client is a instance of RedisNamespace class' do 28 | expect(redis).to be_a ::Redis 29 | end 30 | 31 | it 'connection has correct values' do 32 | expect(redis.connection[:host]).to eq('localhost') 33 | expect(redis.connection[:db]).to eq(0) 34 | end 35 | end 36 | 37 | context 'with redis_db specified in options' do 38 | before do 39 | redis_options[:redis_db] = 4 40 | end 41 | 42 | it 'client has correct redis_url' do 43 | expect(raw_client.config.server_url).to eq("#{redis_url}/4") 44 | end 45 | 46 | it 'connection has correct values' do 47 | expect(raw_client.config.host).to eq('localhost') 48 | expect(raw_client.config.db).to eq(4) 49 | end 50 | end 51 | end 52 | 53 | context 'when namespace is specified' do 54 | let(:namespace) { 'sidekiq_mailman' } 55 | let(:mailbox) { 56 | build_mailbox( 57 | delivery_method: :sidekiq, 58 | delivery_options: { 59 | redis_url: redis_url, 60 | namespace: namespace 61 | } 62 | ) 63 | } 64 | 65 | it 'client has same specified namespace' do 66 | expect(redis.namespace).to eq(namespace) 67 | end 68 | 69 | it 'client is a instance of RedisNamespace class' do 70 | expect(redis).to be_a ::Redis::Namespace 71 | end 72 | end 73 | 74 | context 'when sentinel is specified' do 75 | let(:redis_url) { 'redis://:mypassword@sentinel-master:6379' } 76 | let(:sentinels) { [{ host: '10.0.0.1', port: '26379' }] } 77 | let(:mailbox) { 78 | build_mailbox( 79 | delivery_method: :sidekiq, 80 | delivery_options: { 81 | redis_url: redis_url, 82 | sentinels: sentinels 83 | } 84 | ) 85 | } 86 | 87 | before { ::RedisClient::SentinelConfig.any_instance.stubs(:resolve_master).returns(RedisClient::Config.new(**sentinels.first)) } 88 | 89 | it 'client has same specified sentinel params' do 90 | expect(raw_client.config).to be_a RedisClient::SentinelConfig 91 | expect(raw_client.config.host).to eq('10.0.0.1') 92 | expect(raw_client.config.name).to eq('sentinel-master') 93 | expect(raw_client.config.password).to eq('mypassword') 94 | expect(raw_client.config.sentinels.map(&:server_url)).to eq(["redis://10.0.0.1:26379"]) 95 | end 96 | 97 | context 'with separate Sentinel username and password' do 98 | let(:sentinel_username) { 'my-sentinel-user' } 99 | let(:sentinel_password) { 'my-sentinel-pass' } 100 | let(:mailbox) { 101 | build_mailbox( 102 | delivery_method: :sidekiq, 103 | delivery_options: { 104 | redis_url: redis_url, 105 | sentinels: sentinels, 106 | sentinel_username: sentinel_username, 107 | sentinel_password: sentinel_password 108 | } 109 | ) 110 | } 111 | 112 | it 'client uses Sentinel username and password' do 113 | expect(raw_client.config).to be_a RedisClient::SentinelConfig 114 | expect(raw_client.config.password).to eq('mypassword') 115 | 116 | sentinels = raw_client.config.sentinels 117 | 118 | expect(sentinels.map(&:username).uniq).to eq([sentinel_username]) 119 | expect(sentinels.map(&:password).uniq).to eq([sentinel_password]) 120 | end 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/lib/arbitration/redis_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mail_room/arbitration/redis' 3 | 4 | describe MailRoom::Arbitration::Redis do 5 | let(:mailbox) { 6 | build_mailbox( 7 | arbitration_options: { 8 | namespace: "mail_room", 9 | redis_url: ENV['REDIS_URL'] 10 | } 11 | ) 12 | } 13 | let(:options) { described_class::Options.new(mailbox) } 14 | subject { described_class.new(options) } 15 | 16 | # Private, but we don't care. 17 | let(:redis) { subject.send(:client) } 18 | let(:raw_client) { redis._client } 19 | 20 | describe '#deliver?' do 21 | context "when called the first time" do 22 | after do 23 | redis.del("delivered:123") 24 | end 25 | 26 | it "returns true" do 27 | expect(subject.deliver?(123)).to be_truthy 28 | end 29 | 30 | it "increments the delivered flag" do 31 | subject.deliver?(123) 32 | 33 | expect(redis.get("delivered:123")).to eq("1") 34 | end 35 | 36 | it "sets an expiration on the delivered flag" do 37 | subject.deliver?(123) 38 | 39 | expect(redis.ttl("delivered:123")).to be > 0 40 | end 41 | end 42 | 43 | context "when called the second time" do 44 | before do 45 | #Short expiration, 1 second, for testing 46 | subject.deliver?(123, 1) 47 | end 48 | 49 | after do 50 | redis.del("delivered:123") 51 | end 52 | 53 | it "returns false" do 54 | expect(subject.deliver?(123, 1)).to be_falsey 55 | end 56 | 57 | it "after expiration returns true" do 58 | # Fails locally because fakeredis returns 0, not false 59 | expect(subject.deliver?(123, 1)).to be_falsey 60 | sleep(redis.ttl("delivered:123")+1) 61 | expect(subject.deliver?(123, 1)).to be_truthy 62 | end 63 | end 64 | 65 | context "when called for another uid" do 66 | before do 67 | subject.deliver?(123) 68 | end 69 | 70 | after do 71 | redis.del("delivered:123") 72 | redis.del("delivered:124") 73 | end 74 | 75 | it "returns true" do 76 | expect(subject.deliver?(124)).to be_truthy 77 | end 78 | end 79 | end 80 | 81 | context 'redis client connection params' do 82 | context 'when only url is present' do 83 | let(:redis_url) { ENV.fetch('REDIS_URL', 'redis://localhost:6379') } 84 | let(:mailbox) { 85 | build_mailbox( 86 | arbitration_options: { 87 | redis_url: redis_url 88 | } 89 | ) 90 | } 91 | 92 | after do 93 | redis.del("delivered:123") 94 | end 95 | 96 | it 'client has same specified url' do 97 | subject.deliver?(123) 98 | 99 | expect(raw_client.config.server_url).to eq redis_url 100 | end 101 | 102 | it 'client is a instance of Redis class' do 103 | expect(redis).to be_a Redis 104 | end 105 | end 106 | 107 | context 'when namespace is present' do 108 | let(:namespace) { 'mail_room' } 109 | let(:mailbox) { 110 | build_mailbox( 111 | arbitration_options: { 112 | namespace: namespace 113 | } 114 | ) 115 | } 116 | 117 | it 'client has same specified namespace' do 118 | expect(redis.namespace).to eq(namespace) 119 | end 120 | 121 | it 'client is a instance of RedisNamespace class' do 122 | expect(redis).to be_a ::Redis::Namespace 123 | end 124 | end 125 | 126 | context 'when sentinel is present' do 127 | let(:redis_url) { 'redis://:mypassword@sentinel-master:6379' } 128 | let(:sentinels) { [{ host: '10.0.0.1', port: '26379' }] } 129 | let(:mailbox) { 130 | build_mailbox( 131 | arbitration_options: { 132 | redis_url: redis_url, 133 | sentinels: sentinels 134 | } 135 | ) 136 | } 137 | 138 | before { ::RedisClient::SentinelConfig.any_instance.stubs(:resolve_master).returns(RedisClient::Config.new(**sentinels.first)) } 139 | 140 | it 'client has same specified sentinel params' do 141 | expect(raw_client.config).to be_a RedisClient::SentinelConfig 142 | expect(raw_client.config.name).to eq('sentinel-master') 143 | expect(raw_client.config.host).to eq('10.0.0.1') 144 | expect(raw_client.config.password).to eq('mypassword') 145 | expect(raw_client.config.sentinels.map(&:server_url)).to eq(["redis://10.0.0.1:26379"]) 146 | end 147 | 148 | context 'with separate Sentinel username and password' do 149 | let(:sentinel_username) { 'my-sentinel-user' } 150 | let(:sentinel_password) { 'my-sentinel-pass' } 151 | let(:mailbox) { 152 | build_mailbox( 153 | arbitration_options: { 154 | redis_url: redis_url, 155 | sentinels: sentinels, 156 | sentinel_username: sentinel_username, 157 | sentinel_password: sentinel_password 158 | } 159 | ) 160 | } 161 | 162 | it 'client uses Sentinel username and password' do 163 | expect(raw_client.config).to be_a RedisClient::SentinelConfig 164 | expect(raw_client.config.password).to eq('mypassword') 165 | 166 | sentinels = raw_client.config.sentinels 167 | expect(sentinels.map(&:username).uniq).to eq([sentinel_username]) 168 | expect(sentinels.map(&:password).uniq).to eq([sentinel_password]) 169 | end 170 | end 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /spec/lib/mailbox_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MailRoom::Mailbox do 4 | let(:sample_message) { MailRoom::Message.new(uid: 123, body: 'a message') } 5 | 6 | context 'with IMAP configuration' do 7 | subject { build_mailbox } 8 | 9 | describe '#imap?' do 10 | it 'configured as an IMAP inbox' do 11 | expect(subject.imap?).to be true 12 | expect(subject.microsoft_graph?).to be false 13 | end 14 | end 15 | end 16 | 17 | describe "#deliver" do 18 | context "with arbitration_method of noop" do 19 | it 'arbitrates with a Noop instance' do 20 | mailbox = build_mailbox({arbitration_method: 'noop'}) 21 | noop = stub(:deliver?) 22 | MailRoom::Arbitration['noop'].stubs(new: noop) 23 | 24 | uid = 123 25 | 26 | noop.expects(:deliver?).with(uid) 27 | 28 | mailbox.deliver?(uid) 29 | end 30 | end 31 | 32 | context "with arbitration_method of redis" do 33 | it 'arbitrates with a Redis instance' do 34 | mailbox = build_mailbox({arbitration_method: 'redis'}) 35 | redis = stub(:deliver?) 36 | MailRoom::Arbitration['redis'].stubs(new: redis) 37 | uid = 123 38 | redis.expects(:deliver?).with(uid) 39 | 40 | mailbox.deliver?(uid) 41 | end 42 | end 43 | 44 | context "with delivery_method of noop" do 45 | it 'delivers with a Noop instance' do 46 | mailbox = build_mailbox({delivery_method: 'noop'}) 47 | noop = stub(:deliver) 48 | MailRoom::Delivery['noop'].stubs(new: noop) 49 | 50 | noop.expects(:deliver).with(sample_message.body) 51 | 52 | mailbox.deliver(sample_message) 53 | end 54 | end 55 | 56 | context "with delivery_method of logger" do 57 | it 'delivers with a Logger instance' do 58 | mailbox = build_mailbox({delivery_method: 'logger'}) 59 | logger = stub(:deliver) 60 | MailRoom::Delivery['logger'].stubs(new: logger) 61 | 62 | logger.expects(:deliver).with(sample_message.body) 63 | 64 | mailbox.deliver(sample_message) 65 | end 66 | end 67 | 68 | context "with delivery_method of postback" do 69 | it 'delivers with a Postback instance' do 70 | mailbox = build_mailbox({delivery_method: 'postback'}) 71 | postback = stub(:deliver) 72 | MailRoom::Delivery['postback'].stubs(new: postback) 73 | 74 | postback.expects(:deliver).with(sample_message.body) 75 | 76 | mailbox.deliver(sample_message) 77 | end 78 | end 79 | 80 | context "with delivery_method of letter_opener" do 81 | it 'delivers with a LetterOpener instance' do 82 | mailbox = build_mailbox({delivery_method: 'letter_opener'}) 83 | letter_opener = stub(:deliver) 84 | MailRoom::Delivery['letter_opener'].stubs(new: letter_opener) 85 | 86 | letter_opener.expects(:deliver).with(sample_message.body) 87 | 88 | mailbox.deliver(sample_message) 89 | end 90 | end 91 | 92 | context "without an RFC822 attribute" do 93 | it "doesn't deliver the message" do 94 | mailbox = build_mailbox({ name: "magic mailbox", delivery_method: 'noop' }) 95 | noop = stub(:deliver) 96 | MailRoom::Delivery['noop'].stubs(new: noop) 97 | noop.expects(:deliver).never 98 | 99 | mailbox.deliver(MailRoom::Message.new(uid: 1234, body: nil)) 100 | end 101 | end 102 | 103 | context "with ssl options hash" do 104 | it 'replaces verify mode with constant' do 105 | mailbox = build_mailbox({ssl: {verify_mode: :none}}) 106 | 107 | expect(mailbox.ssl_options).to eq({verify_mode: OpenSSL::SSL::VERIFY_NONE}) 108 | end 109 | end 110 | 111 | context 'structured logger setup' do 112 | it 'sets up the logger correctly and does not error' do 113 | mailbox = build_mailbox({ name: "magic mailbox", logger: { log_path: '/dev/null' } }) 114 | 115 | expect{ mailbox.logger.info(message: "asdf") }.not_to raise_error 116 | end 117 | 118 | it 'accepts stdout symbol to mean STDOUT' do 119 | mailbox = build_mailbox({ name: "magic mailbox", logger: { log_path: :stdout } }) 120 | 121 | expect{ mailbox.logger.info(message: "asdf") }.not_to raise_error 122 | end 123 | 124 | it 'sets up the noop logger correctly and does not error' do 125 | mailbox = build_mailbox({ name: "magic mailbox" }) 126 | 127 | expect{ mailbox.logger.info(message: "asdf") }.not_to raise_error 128 | end 129 | end 130 | end 131 | 132 | describe "#validate!" do 133 | context "with missing configuration" do 134 | it 'raises an error' do 135 | expect { build_mailbox({name: nil}) }.to raise_error(MailRoom::ConfigurationError) 136 | expect { build_mailbox({host: nil}) }.to raise_error(MailRoom::ConfigurationError) 137 | end 138 | end 139 | 140 | context "with Microsoft Graph configuration" do 141 | let(:options) do 142 | { 143 | arbitration_method: 'redis', 144 | }.merge(REQUIRED_MICROSOFT_GRAPH_DEFAULTS) 145 | end 146 | 147 | subject { build_mailbox(options) } 148 | 149 | def delete_inbox_option(key) 150 | options[:inbox_options] = options[:inbox_options].dup.delete(key) 151 | end 152 | 153 | it 'allows password omission' do 154 | expect { subject }.not_to raise_error 155 | end 156 | 157 | it 'configured as a Microsoft Graph inbox' do 158 | expect(subject.imap?).to be false 159 | expect(subject.microsoft_graph?).to be true 160 | end 161 | 162 | it 'raises an error when the inbox options are not present' do 163 | options.delete(:inbox_options) 164 | 165 | expect { subject }.to raise_error(MailRoom::ConfigurationError) 166 | end 167 | 168 | %i[tenant_id client_id client_secret].each do |item| 169 | it "raises an error when the #{item} is not present" do 170 | delete_inbox_option(item) 171 | 172 | expect { subject }.to raise_error(MailRoom::ConfigurationError) 173 | end 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/mail_room/imap/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MailRoom 4 | module IMAP 5 | class Connection < MailRoom::Connection 6 | def initialize(mailbox) 7 | super 8 | 9 | # log in and set the mailbox 10 | reset 11 | setup 12 | end 13 | 14 | # is the connection logged in? 15 | # @return [Boolean] 16 | def logged_in? 17 | @logged_in 18 | end 19 | 20 | # is the connection blocked idling? 21 | # @return [Boolean] 22 | def idling? 23 | @idling 24 | end 25 | 26 | # is the imap connection closed? 27 | # @return [Boolean] 28 | def disconnected? 29 | imap.disconnected? 30 | end 31 | 32 | # is the connection ready to idle? 33 | # @return [Boolean] 34 | def ready_to_idle? 35 | logged_in? && !idling? 36 | end 37 | 38 | def quit 39 | stop_idling 40 | reset 41 | end 42 | 43 | def wait 44 | # in case we missed any between idles 45 | process_mailbox 46 | 47 | idle 48 | 49 | process_mailbox 50 | rescue Net::IMAP::Error, IOError => e 51 | @mailbox.logger.warn({ context: @mailbox.context, action: 'Disconnected. Resetting...', error: e.message }) 52 | reset 53 | setup 54 | end 55 | 56 | private 57 | 58 | def reset 59 | @imap = nil 60 | @logged_in = false 61 | @idling = false 62 | end 63 | 64 | def setup 65 | @mailbox.logger.info({ context: @mailbox.context, action: 'Starting TLS session' }) 66 | start_tls 67 | 68 | @mailbox.logger.info({ context: @mailbox.context, action: 'Logging into mailbox' }) 69 | log_in 70 | 71 | @mailbox.logger.info({ context: @mailbox.context, action: 'Setting mailbox' }) 72 | set_mailbox 73 | end 74 | 75 | # build a net/imap connection to google imap 76 | def imap 77 | @imap ||= Net::IMAP.new(@mailbox.host, port: @mailbox.port, ssl: @mailbox.ssl_options) 78 | end 79 | 80 | # start a TLS session 81 | def start_tls 82 | imap.starttls if @mailbox.start_tls 83 | end 84 | 85 | # send the imap login command to google 86 | def log_in 87 | imap.login(@mailbox.email, @mailbox.password) 88 | @logged_in = true 89 | end 90 | 91 | # select the mailbox name we want to use 92 | def set_mailbox 93 | imap.select(@mailbox.name) if logged_in? 94 | end 95 | 96 | # is the response for a new message? 97 | # @param response [Net::IMAP::TaggedResponse] the imap response from idle 98 | # @return [Boolean] 99 | def message_exists?(response) 100 | response.respond_to?(:name) && response.name == 'EXISTS' 101 | end 102 | 103 | # @private 104 | def idle_handler 105 | ->(response) { imap.idle_done if message_exists?(response) } 106 | end 107 | 108 | # maintain an imap idle connection 109 | def idle 110 | return unless ready_to_idle? 111 | 112 | @mailbox.logger.info({ context: @mailbox.context, action: 'Idling' }) 113 | @idling = true 114 | 115 | imap.idle(@mailbox.idle_timeout, &idle_handler) 116 | ensure 117 | @idling = false 118 | end 119 | 120 | # trigger the idle to finish and wait for the thread to finish 121 | def stop_idling 122 | return unless idling? 123 | 124 | imap.idle_done 125 | 126 | # idling_thread.join 127 | # self.idling_thread = nil 128 | end 129 | 130 | def process_mailbox 131 | return unless @new_message_handler 132 | 133 | @mailbox.logger.info({ context: @mailbox.context, action: 'Processing started' }) 134 | 135 | msgs = new_messages 136 | any_deletions = msgs. 137 | # deliver each new message, collect success 138 | map(&@new_message_handler). 139 | # include messages with success 140 | zip(msgs). 141 | # filter failed deliveries, collect message 142 | select(&:first).map(&:last). 143 | # scrub delivered messages 144 | map { |message| scrub(message) } 145 | .any? 146 | 147 | imap.expunge if @mailbox.expunge_deleted && any_deletions 148 | end 149 | 150 | def scrub(message) 151 | if @mailbox.delete_after_delivery 152 | imap.store(message.seqno, '+FLAGS', [Net::IMAP::DELETED]) 153 | true 154 | end 155 | end 156 | 157 | # @private 158 | # fetch all messages for the new message ids 159 | def new_messages 160 | # Both of these calls may results in 161 | # imap raising an EOFError, we handle 162 | # this exception in the watcher 163 | messages_for_ids(new_message_ids) 164 | end 165 | 166 | # TODO: label messages? 167 | # @imap.store(id, "+X-GM-LABELS", [label]) 168 | 169 | # @private 170 | # search for all new (unseen) message ids 171 | # @return [Array] message ids 172 | def new_message_ids 173 | # uid_search still leaves messages UNSEEN 174 | all_unread = imap.uid_search(@mailbox.search_command) 175 | 176 | all_unread = all_unread.slice(0, @mailbox.limit_max_unread) if @mailbox.limit_max_unread.to_i > 0 177 | 178 | to_deliver = all_unread.select { |uid| @mailbox.deliver?(uid) } 179 | @mailbox.logger.info({ context: @mailbox.context, action: 'Getting new messages', 180 | unread: { count: all_unread.count, ids: all_unread }, to_be_delivered: { count: to_deliver.count, ids: to_deliver } }) 181 | to_deliver 182 | end 183 | 184 | # @private 185 | # fetch the email for all given ids in RFC822 format 186 | # @param ids [Array] list of message ids 187 | # @return [Array] the net/imap messages for the given ids 188 | def messages_for_ids(uids) 189 | return [] if uids.empty? 190 | 191 | # uid_fetch marks as SEEN, will not be re-fetched for UNSEEN 192 | imap_messages = imap.uid_fetch(uids, 'RFC822') 193 | 194 | imap_messages.each_with_object([]) do |msg, messages| 195 | messages << ::MailRoom::IMAP::Message.new(uid: msg.attr['UID'], body: msg.attr['RFC822'], seqno: msg.seqno) 196 | end 197 | end 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /lib/mail_room/mailbox.rb: -------------------------------------------------------------------------------- 1 | require "mail_room/delivery" 2 | require "mail_room/arbitration" 3 | require "mail_room/imap" 4 | require "mail_room/microsoft_graph" 5 | 6 | module MailRoom 7 | # Mailbox Configuration fields 8 | MAILBOX_FIELDS = [ 9 | :email, 10 | :inbox_method, 11 | :inbox_options, 12 | :password, 13 | :host, 14 | :port, 15 | :ssl, 16 | :start_tls, 17 | :limit_max_unread, #to avoid 'Error in IMAP command UID FETCH: Too long argument' 18 | :idle_timeout, 19 | :search_command, 20 | :name, 21 | :delete_after_delivery, 22 | :expunge_deleted, 23 | :delivery_klass, 24 | :delivery_method, # :noop, :logger, :postback, :letter_opener 25 | :log_path, # for logger 26 | :delivery_url, # for postback 27 | :delivery_token, # for postback 28 | :content_type, # for postback 29 | :location, # for letter_opener 30 | :delivery_options, 31 | :arbitration_method, 32 | :arbitration_options, 33 | :logger 34 | ] 35 | 36 | ConfigurationError = Class.new(RuntimeError) 37 | IdleTimeoutTooLarge = Class.new(RuntimeError) 38 | 39 | # Holds configuration for each of the email accounts we wish to monitor 40 | # and deliver email to when new emails arrive over imap 41 | Mailbox = Struct.new(*MAILBOX_FIELDS) do 42 | # Keep it to 29 minutes or less 43 | # The IMAP serve will close the connection after 30 minutes of inactivity 44 | # (which sending IDLE and then nothing technically is), so we re-idle every 45 | # 29 minutes, as suggested by the spec: https://tools.ietf.org/html/rfc2177 46 | IMAP_IDLE_TIMEOUT = 29 * 60 # 29 minutes in in seconds 47 | 48 | IMAP_CONFIGURATION = [:name, :email, :password, :host, :port].freeze 49 | MICROSOFT_GRAPH_CONFIGURATION = [:name, :email].freeze 50 | MICROSOFT_GRAPH_INBOX_OPTIONS = [:tenant_id, :client_id, :client_secret].freeze 51 | 52 | # Default attributes for the mailbox configuration 53 | DEFAULTS = { 54 | search_command: 'UNSEEN', 55 | delivery_method: 'postback', 56 | host: 'imap.gmail.com', 57 | port: 993, 58 | ssl: true, 59 | start_tls: false, 60 | limit_max_unread: 0, 61 | idle_timeout: IMAP_IDLE_TIMEOUT, 62 | delete_after_delivery: false, 63 | expunge_deleted: false, 64 | delivery_options: {}, 65 | arbitration_method: 'noop', 66 | arbitration_options: {}, 67 | logger: {} 68 | } 69 | 70 | # Store the configuration and require the appropriate delivery method 71 | # @param attributes [Hash] configuration options 72 | def initialize(attributes={}) 73 | super(*DEFAULTS.merge(attributes).values_at(*members)) 74 | 75 | validate! 76 | end 77 | 78 | def logger 79 | @logger ||= 80 | case self[:logger] 81 | when Logger 82 | self[:logger] 83 | else 84 | self[:logger] ||= {} 85 | MailRoom::Logger::Structured.new(normalize_log_path(self[:logger][:log_path])) 86 | end 87 | end 88 | 89 | def delivery_klass 90 | self[:delivery_klass] ||= Delivery[delivery_method] 91 | end 92 | 93 | def arbitration_klass 94 | Arbitration[arbitration_method] 95 | end 96 | 97 | def delivery 98 | @delivery ||= delivery_klass.new(parsed_delivery_options) 99 | end 100 | 101 | def arbitrator 102 | @arbitrator ||= arbitration_klass.new(parsed_arbitration_options) 103 | end 104 | 105 | def deliver?(uid) 106 | logger.info({context: context, uid: uid, action: "asking arbiter to deliver", arbitrator: arbitrator.class.name}) 107 | 108 | arbitrator.deliver?(uid) 109 | end 110 | 111 | # deliver the email message 112 | # @param message [MailRoom::Message] 113 | def deliver(message) 114 | body = message.body 115 | return true unless body 116 | 117 | logger.info({context: context, uid: message.uid, action: "sending to deliverer", deliverer: delivery.class.name, byte_size: body.bytesize}) 118 | delivery.deliver(body) 119 | end 120 | 121 | # true, false, or ssl options hash 122 | def ssl_options 123 | replace_verify_mode(ssl) 124 | end 125 | 126 | def context 127 | { email: self.email, name: self.name } 128 | end 129 | 130 | def imap? 131 | !microsoft_graph? 132 | end 133 | 134 | def microsoft_graph? 135 | self[:inbox_method].to_s == 'microsoft_graph' 136 | end 137 | 138 | def validate! 139 | if microsoft_graph? 140 | validate_microsoft_graph! 141 | else 142 | validate_imap! 143 | end 144 | end 145 | 146 | private 147 | 148 | def validate_imap! 149 | if self[:idle_timeout] > IMAP_IDLE_TIMEOUT 150 | raise IdleTimeoutTooLarge, 151 | "Please use an idle timeout smaller than #{29*60} to prevent " \ 152 | "IMAP server disconnects" 153 | end 154 | 155 | IMAP_CONFIGURATION.each do |k| 156 | if self[k].nil? 157 | raise ConfigurationError, 158 | "Field :#{k} is required in Mailbox: #{inspect}" 159 | end 160 | end 161 | end 162 | 163 | def validate_microsoft_graph! 164 | raise ConfigurationError, "Missing inbox_options in Mailbox: #{inspect}" unless self.inbox_options.is_a?(Hash) 165 | 166 | MICROSOFT_GRAPH_CONFIGURATION.each do |k| 167 | if self[k].nil? 168 | raise ConfigurationError, 169 | "Field :#{k} is required in Mailbox: #{inspect}" 170 | end 171 | end 172 | 173 | MICROSOFT_GRAPH_INBOX_OPTIONS.each do |k| 174 | if self[:inbox_options][k].nil? 175 | raise ConfigurationError, 176 | "inbox_options field :#{k} is required in Mailbox: #{inspect}" 177 | end 178 | end 179 | end 180 | 181 | def parsed_arbitration_options 182 | arbitration_klass::Options.new(self) 183 | end 184 | 185 | def parsed_delivery_options 186 | delivery_klass::Options.new(self) 187 | end 188 | 189 | def replace_verify_mode(options) 190 | return options unless options.is_a?(Hash) 191 | return options unless options.has_key?(:verify_mode) 192 | 193 | options[:verify_mode] = lookup_verify_mode(options[:verify_mode]) 194 | 195 | options 196 | end 197 | 198 | def lookup_verify_mode(verify_mode) 199 | case verify_mode.to_sym 200 | when :none 201 | OpenSSL::SSL::VERIFY_NONE 202 | when :peer 203 | OpenSSL::SSL::VERIFY_PEER 204 | when :client_once 205 | OpenSSL::SSL::VERIFY_CLIENT_ONCE 206 | when :fail_if_no_peer_cert 207 | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT 208 | end 209 | end 210 | 211 | def normalize_log_path(log_path) 212 | case log_path 213 | when nil, "" 214 | nil 215 | when :stdout, "STDOUT" 216 | STDOUT 217 | when :stderr, "STDERR" 218 | STDERR 219 | else 220 | log_path 221 | end 222 | end 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /lib/mail_room/microsoft_graph/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'oauth2' 5 | 6 | module MailRoom 7 | module MicrosoftGraph 8 | class Connection < MailRoom::Connection 9 | NEXT_PAGE_KEY = '@odata.nextLink' 10 | DEFAULT_POLL_INTERVAL_S = 60 11 | 12 | TooManyRequestsError = Class.new(RuntimeError) 13 | 14 | attr_accessor :token, :throttled_count 15 | 16 | def initialize(mailbox) 17 | super 18 | 19 | reset 20 | setup 21 | end 22 | 23 | def wait 24 | return if stopped? 25 | 26 | process_mailbox 27 | 28 | @throttled_count = 0 29 | wait_for_new_messages 30 | rescue TooManyRequestsError => e 31 | @throttled_count += 1 32 | 33 | @mailbox.logger.warn({ context: @mailbox.context, action: 'Too many requests, backing off...', backoff_s: backoff_secs, error: e.message, error_backtrace: e.backtrace }) 34 | 35 | backoff 36 | rescue IOError => e 37 | @mailbox.logger.warn({ context: @mailbox.context, action: 'Disconnected. Resetting...', error: e.message, error_backtrace: e.backtrace }) 38 | 39 | reset 40 | setup 41 | end 42 | 43 | private 44 | 45 | def wait_for_new_messages 46 | sleep_while_running(poll_interval) 47 | end 48 | 49 | def backoff 50 | sleep_while_running(backoff_secs) 51 | end 52 | 53 | def backoff_secs 54 | [60 * 10, 2**throttled_count].min 55 | end 56 | 57 | # Unless wake up periodically, we won't notice that the thread was stopped 58 | # if we sleep the entire interval. 59 | def sleep_while_running(sleep_interval) 60 | sleep_interval.times do 61 | do_sleep(1) 62 | return if stopped? 63 | end 64 | end 65 | 66 | def do_sleep(interval) 67 | sleep(interval) 68 | end 69 | 70 | def reset 71 | @token = nil 72 | @throttled_count = 0 73 | end 74 | 75 | def setup 76 | @mailbox.logger.info({ context: @mailbox.context, action: 'Retrieving OAuth2 token...' }) 77 | 78 | @token = client.client_credentials.get_token({ scope: scope }) 79 | end 80 | 81 | def client 82 | @client ||= OAuth2::Client.new(client_id, client_secret, 83 | site: azure_ad_endpoint, 84 | authorize_url: "/#{tenant_id}/oauth2/v2.0/authorize", 85 | token_url: "/#{tenant_id}/oauth2/v2.0/token", 86 | auth_scheme: :basic_auth) 87 | end 88 | 89 | def inbox_options 90 | mailbox.inbox_options 91 | end 92 | 93 | def tenant_id 94 | inbox_options[:tenant_id] 95 | end 96 | 97 | def client_id 98 | inbox_options[:client_id] 99 | end 100 | 101 | def client_secret 102 | inbox_options[:client_secret] 103 | end 104 | 105 | def poll_interval 106 | @poll_interval ||= begin 107 | interval = inbox_options[:poll_interval].to_i 108 | 109 | if interval.positive? 110 | interval 111 | else 112 | DEFAULT_POLL_INTERVAL_S 113 | end 114 | end 115 | end 116 | 117 | def process_mailbox 118 | return unless @new_message_handler 119 | 120 | @mailbox.logger.info({ context: @mailbox.context, action: 'Processing started' }) 121 | 122 | new_messages.each do |msg| 123 | success = @new_message_handler.call(msg) 124 | handle_delivered(msg) if success 125 | end 126 | end 127 | 128 | def handle_delivered(msg) 129 | mark_as_read(msg) 130 | delete_message(msg) if @mailbox.delete_after_delivery 131 | end 132 | 133 | def delete_message(msg) 134 | token.delete(msg_url(msg.uid)) 135 | end 136 | 137 | def mark_as_read(msg) 138 | token.patch(msg_url(msg.uid), 139 | headers: { 'Content-Type' => 'application/json' }, 140 | body: { isRead: true }.to_json) 141 | end 142 | 143 | def new_messages 144 | messages_for_ids(new_message_ids) 145 | end 146 | 147 | # Yields a page of message IDs at a time 148 | def new_message_ids 149 | url = unread_messages_url 150 | 151 | Enumerator.new do |block| 152 | loop do 153 | messages, next_page_url = unread_messages(url: url) 154 | messages.each { |msg| block.yield msg } 155 | 156 | break unless next_page_url 157 | 158 | url = next_page_url 159 | end 160 | end 161 | end 162 | 163 | def unread_messages(url:) 164 | body = get(url) 165 | 166 | return [[], nil] unless body 167 | 168 | all_unread = body['value'].map { |msg| msg['id'] } 169 | to_deliver = all_unread.select { |uid| @mailbox.deliver?(uid) } 170 | @mailbox.logger.info({ context: @mailbox.context, action: 'Getting new messages', 171 | unread: { count: all_unread.count, ids: all_unread }, 172 | to_be_delivered: { count: to_deliver.count, ids: to_deliver } }) 173 | [to_deliver, body[NEXT_PAGE_KEY]] 174 | rescue TypeError, JSON::ParserError => e 175 | log_exception('Error parsing JSON response', e) 176 | [[], nil] 177 | end 178 | 179 | # Returns the JSON response 180 | def get(url) 181 | response = token.get(url, { raise_errors: false }) 182 | 183 | # https://docs.microsoft.com/en-us/graph/errors 184 | case response.status 185 | when 509, 429 186 | raise TooManyRequestsError 187 | when 400..599 188 | raise OAuth2::Error, response 189 | end 190 | 191 | return unless response.body 192 | 193 | body = JSON.parse(response.body) 194 | 195 | raise TypeError, 'Response did not contain value hash' unless body.is_a?(Hash) && body.key?('value') 196 | 197 | body 198 | end 199 | 200 | def messages_for_ids(message_ids) 201 | message_ids.each_with_object([]) do |id, arr| 202 | response = token.get(rfc822_msg_url(id)) 203 | 204 | arr << ::MailRoom::Message.new(uid: id, body: response.body) 205 | end 206 | end 207 | 208 | def base_url 209 | "#{graph_endpoint}/v1.0/users/#{mailbox.email}/mailFolders/#{mailbox.name}/messages" 210 | end 211 | 212 | def unread_messages_url 213 | "#{base_url}?$filter=isRead eq false" 214 | end 215 | 216 | def msg_url(id) 217 | # Attempting to use the base_url fails with "The OData request is not supported" 218 | "#{graph_endpoint}/v1.0/users/#{mailbox.email}/messages/#{id}" 219 | end 220 | 221 | def rfc822_msg_url(id) 222 | # Attempting to use the base_url fails with "The OData request is not supported" 223 | "#{msg_url(id)}/$value" 224 | end 225 | 226 | def log_exception(message, exception) 227 | @mailbox.logger.warn({ context: @mailbox.context, message: message, exception: exception.to_s }) 228 | end 229 | 230 | def scope 231 | "#{graph_endpoint}/.default" 232 | end 233 | 234 | def graph_endpoint 235 | inbox_options[:graph_endpoint] || 'https://graph.microsoft.com' 236 | end 237 | 238 | def azure_ad_endpoint 239 | inbox_options[:azure_ad_endpoint] || 'https://login.microsoftonline.com' 240 | end 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /spec/lib/microsoft_graph/connection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | require 'spec_helper' 5 | require 'json' 6 | require 'webmock/rspec' 7 | 8 | describe MailRoom::MicrosoftGraph::Connection do 9 | let(:tenant_id) { options[:inbox_options][:tenant_id] } 10 | let(:options) do 11 | { 12 | delete_after_delivery: true, 13 | expunge_deleted: true 14 | }.merge(REQUIRED_MICROSOFT_GRAPH_DEFAULTS) 15 | end 16 | let(:mailbox) { build_mailbox(options) } 17 | let(:graph_endpoint) { 'https://graph.microsoft.com' } 18 | let(:azure_ad_endpoint) { 'https://login.microsoftonline.com' } 19 | let(:base_url) { "#{graph_endpoint}/v1.0/users/user@example.com/mailFolders/inbox/messages" } 20 | let(:message_base_url) { "#{graph_endpoint}/v1.0/users/user@example.com/messages" } 21 | 22 | let(:connection) { described_class.new(mailbox) } 23 | let(:uid) { 1 } 24 | let(:access_token) { SecureRandom.hex } 25 | let(:refresh_token) { SecureRandom.hex } 26 | let(:expires_in) { Time.now + 3600 } 27 | let(:unread_messages_body) { '' } 28 | let(:status) { 200 } 29 | let!(:stub_token) do 30 | stub_request(:post, "#{azure_ad_endpoint}/#{tenant_id}/oauth2/v2.0/token").to_return( 31 | body: { 'access_token' => access_token, 'refresh_token' => refresh_token, 'expires_in' => expires_in }.to_json, 32 | headers: { 'Content-Type' => 'application/json' } 33 | ) 34 | end 35 | let!(:stub_unread_messages_request) do 36 | stub_request(:get, "#{base_url}?$filter=isRead%20eq%20false").to_return( 37 | status: status, 38 | body: unread_messages_body.to_json, 39 | headers: { 'Content-Type' => 'application/json' } 40 | ) 41 | end 42 | 43 | before do 44 | WebMock.enable! 45 | end 46 | 47 | context '#quit' do 48 | it 'returns false' do 49 | expect(connection.stopped?).to be_falsey 50 | end 51 | 52 | it 'returns true' do 53 | connection.quit 54 | 55 | expect(connection.stopped?).to be_truthy 56 | end 57 | 58 | it 'does not attempt to process the mailbox' do 59 | connection.quit 60 | 61 | connection.expects(:process_mailbox).times(0) 62 | connection.wait 63 | end 64 | end 65 | 66 | context '#wait' do 67 | before do 68 | connection.stubs(:do_sleep) 69 | end 70 | 71 | describe 'poll interval' do 72 | it 'defaults to 60 seconds' do 73 | expect(connection.send(:poll_interval)).to eq(60) 74 | end 75 | 76 | it 'calls do_sleep 60 times' do 77 | connection.expects(:do_sleep).with(1).times(60) 78 | 79 | connection.wait 80 | end 81 | 82 | context 'interval set to 10' do 83 | let(:options) do 84 | { 85 | inbox_method: :microsoft_graph, 86 | inbox_options: { 87 | tenant_id: '98776', 88 | client_id: '12345', 89 | client_secret: 'MY-SECRET', 90 | poll_interval: '10' 91 | } 92 | } 93 | end 94 | 95 | it 'sets the poll interval to 10' do 96 | expect(connection.send(:poll_interval)).to eq(10) 97 | end 98 | 99 | it 'calls do_sleep 10 times' do 100 | connection.expects(:do_sleep).with(1).times(10) 101 | 102 | connection.wait 103 | end 104 | end 105 | end 106 | 107 | shared_examples 'with a single message' do 108 | let(:message_id) { SecureRandom.hex } 109 | let(:unread_messages_body) { { value: ['id' => message_id] } } 110 | let(:message_url) { "#{message_base_url}/#{message_id}" } 111 | let(:message_body) { 'hello world' } 112 | 113 | it 'requests message ID' do 114 | stub_get = stub_request(:get, "#{message_url}/$value").to_return( 115 | status: 200, 116 | body: message_body 117 | ) 118 | stub_patch = stub_request(:patch, message_url).with(body: { "isRead": true }.to_json) 119 | stub_delete = stub_request(:delete, message_url) 120 | message_count = 0 121 | 122 | connection.on_new_message do |message| 123 | message_count += 1 124 | expect(message.uid).to eq(message_id) 125 | expect(message.body).to eq(message_body) 126 | end 127 | 128 | connection.wait 129 | 130 | assert_requested(stub_token) 131 | assert_requested(stub_unread_messages_request) 132 | assert_requested(stub_get) 133 | assert_requested(stub_patch) 134 | assert_requested(stub_delete) 135 | expect(message_count).to eq(1) 136 | end 137 | end 138 | 139 | context 'with default Azure settings' do 140 | before do 141 | puts options 142 | end 143 | it_behaves_like 'with a single message' 144 | end 145 | 146 | # https://docs.microsoft.com/en-us/graph/deployments 147 | context 'with an alternative Azure deployment' do 148 | let(:graph_endpoint) { 'https://graph.microsoft.us' } 149 | let(:azure_ad_endpoint) { 'https://login.microsoftonline.us' } 150 | let(:options) do 151 | { 152 | inbox_method: :microsoft_graph, 153 | delete_after_delivery: true, 154 | expunge_deleted: true, 155 | inbox_options: { 156 | tenant_id: '98776', 157 | client_id: '12345', 158 | client_secret: 'MY-SECRET', 159 | graph_endpoint: 'https://graph.microsoft.us', 160 | azure_ad_endpoint: 'https://login.microsoftonline.us' 161 | } 162 | } 163 | end 164 | 165 | it_behaves_like 'with a single message' 166 | end 167 | 168 | context 'with multiple pages of messages' do 169 | let(:message_ids) { [SecureRandom.hex, SecureRandom.hex] } 170 | let(:next_page_url) { "#{graph_endpoint}/v1.0/nextPage" } 171 | let(:unread_messages_body) { { value: ['id' => message_ids.first], '@odata.nextLink' => next_page_url } } 172 | let(:message_body) { 'hello world' } 173 | 174 | it 'requests message ID' do 175 | stub_request(:get, next_page_url).to_return( 176 | status: 200, 177 | body: { value: ['id' => message_ids[1]] }.to_json 178 | ) 179 | 180 | stubs = [] 181 | message_ids.each do |message_id| 182 | rfc822_msg_url = "#{message_base_url}/#{message_id}/$value" 183 | stubs << stub_request(:get, rfc822_msg_url).to_return( 184 | status: 200, 185 | body: message_body 186 | ) 187 | 188 | msg_url = "#{message_base_url}/#{message_id}" 189 | stubs << stub_request(:patch, msg_url).with(body: { "isRead": true }.to_json) 190 | stubs << stub_request(:delete, msg_url) 191 | end 192 | 193 | message_count = 0 194 | 195 | connection.on_new_message do |message| 196 | expect(message.uid).to eq(message_ids[message_count]) 197 | expect(message.body).to eq(message_body) 198 | message_count += 1 199 | end 200 | 201 | connection.wait 202 | 203 | stubs.each { |stub| assert_requested(stub) } 204 | expect(message_count).to eq(2) 205 | end 206 | end 207 | 208 | shared_examples 'request backoff' do 209 | it 'backs off' do 210 | connection.expects(:backoff) 211 | 212 | connection.on_new_message {} 213 | connection.wait 214 | 215 | expect(connection.throttled_count).to eq(1) 216 | end 217 | end 218 | 219 | context 'too many requests' do 220 | let(:status) { 429 } 221 | 222 | it_behaves_like 'request backoff' 223 | end 224 | 225 | context 'too much bandwidth' do 226 | let(:status) { 509 } 227 | 228 | it_behaves_like 'request backoff' 229 | end 230 | 231 | context 'invalid JSON response' do 232 | let(:body) { 'this is something' } 233 | 234 | it 'ignores the message and logs a warning' do 235 | mailbox.logger.expects(:warn) 236 | 237 | connection.on_new_message {} 238 | connection.wait 239 | end 240 | end 241 | 242 | context '500 error' do 243 | let(:status) { 500 } 244 | 245 | it 'terminates due to error' do 246 | connection.on_new_message {} 247 | 248 | expect { connection.wait }.to raise_error(OAuth2::Error) 249 | end 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2023-03-15 01:41:21 UTC using RuboCop version 1.48.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 5 10 | # This cop supports safe autocorrection (--autocorrect). 11 | # Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. 12 | # Include: **/*.gemspec 13 | Gemspec/OrderedDependencies: 14 | Exclude: 15 | - 'mail_room.gemspec' 16 | 17 | # Offense count: 1 18 | # Configuration parameters: Severity, Include. 19 | # Include: **/*.gemspec 20 | Gemspec/RequiredRubyVersion: 21 | Exclude: 22 | - 'mail_room.gemspec' 23 | 24 | # Offense count: 5 25 | # This cop supports safe autocorrection (--autocorrect). 26 | Layout/BlockEndNewline: 27 | Exclude: 28 | - 'spec/lib/delivery/que_spec.rb' 29 | 30 | # Offense count: 5 31 | # This cop supports safe autocorrection (--autocorrect). 32 | # Configuration parameters: EnforcedStyle, IndentOneStep, IndentationWidth. 33 | # SupportedStyles: case, end 34 | Layout/CaseIndentation: 35 | Exclude: 36 | - 'lib/mail_room/mailbox.rb' 37 | 38 | # Offense count: 1 39 | # This cop supports safe autocorrection (--autocorrect). 40 | Layout/EmptyLineAfterMagicComment: 41 | Exclude: 42 | - 'mail_room.gemspec' 43 | 44 | # Offense count: 2 45 | # This cop supports safe autocorrection (--autocorrect). 46 | # Configuration parameters: EnforcedStyle. 47 | # SupportedStyles: around, only_before 48 | Layout/EmptyLinesAroundAccessModifier: 49 | Exclude: 50 | - 'lib/mail_room/coordinator.rb' 51 | - 'lib/mail_room/delivery/que.rb' 52 | 53 | # Offense count: 4 54 | # This cop supports safe autocorrection (--autocorrect). 55 | # Configuration parameters: EnforcedStyle. 56 | # SupportedStyles: empty_lines, no_empty_lines 57 | Layout/EmptyLinesAroundBlockBody: 58 | Exclude: 59 | - 'spec/lib/crash_handler_spec.rb' 60 | - 'spec/lib/delivery/sidekiq_spec.rb' 61 | - 'spec/lib/logger/structured_spec.rb' 62 | 63 | # Offense count: 1 64 | # This cop supports safe autocorrection (--autocorrect). 65 | # Configuration parameters: EnforcedStyle. 66 | # SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines, beginning_only, ending_only 67 | Layout/EmptyLinesAroundClassBody: 68 | Exclude: 69 | - 'lib/mail_room/logger/structured.rb' 70 | 71 | # Offense count: 15 72 | # This cop supports safe autocorrection (--autocorrect). 73 | # Configuration parameters: EnforcedStyle, IndentationWidth. 74 | # SupportedStyles: special_inside_parentheses, consistent, align_braces 75 | Layout/FirstHashElementIndentation: 76 | Exclude: 77 | - 'spec/lib/delivery/que_spec.rb' 78 | - 'spec/lib/logger/structured_spec.rb' 79 | 80 | # Offense count: 5 81 | # This cop supports safe autocorrection (--autocorrect). 82 | # Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle. 83 | # SupportedHashRocketStyles: key, separator, table 84 | # SupportedColonStyles: key, separator, table 85 | # SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit 86 | Layout/HashAlignment: 87 | Exclude: 88 | - 'lib/mail_room/delivery/sidekiq.rb' 89 | 90 | # Offense count: 2 91 | # This cop supports safe autocorrection (--autocorrect). 92 | # Configuration parameters: AllowDoxygenCommentStyle, AllowGemfileRubyComment. 93 | Layout/LeadingCommentSpace: 94 | Exclude: 95 | - 'lib/mail_room/mailbox.rb' 96 | - 'spec/lib/arbitration/redis_spec.rb' 97 | 98 | # Offense count: 5 99 | # This cop supports safe autocorrection (--autocorrect). 100 | Layout/MultilineBlockLayout: 101 | Exclude: 102 | - 'spec/lib/delivery/que_spec.rb' 103 | 104 | # Offense count: 3 105 | # This cop supports safe autocorrection (--autocorrect). 106 | # Configuration parameters: EnforcedStyle. 107 | # SupportedStyles: space, no_space 108 | Layout/SpaceAroundEqualsInParameterDefault: 109 | Exclude: 110 | - 'lib/mail_room/configuration.rb' 111 | - 'lib/mail_room/crash_handler.rb' 112 | - 'lib/mail_room/mailbox.rb' 113 | 114 | # Offense count: 2 115 | # This cop supports safe autocorrection (--autocorrect). 116 | Layout/SpaceAroundKeyword: 117 | Exclude: 118 | - 'lib/mail_room/coordinator.rb' 119 | - 'lib/mail_room/mailbox_watcher.rb' 120 | 121 | # Offense count: 2 122 | # This cop supports safe autocorrection (--autocorrect). 123 | # Configuration parameters: AllowForAlignment, EnforcedStyleForExponentOperator. 124 | # SupportedStylesForExponentOperator: space, no_space 125 | Layout/SpaceAroundOperators: 126 | Exclude: 127 | - 'lib/mail_room/mailbox.rb' 128 | - 'spec/lib/arbitration/redis_spec.rb' 129 | 130 | # Offense count: 7 131 | # This cop supports safe autocorrection (--autocorrect). 132 | # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces. 133 | # SupportedStyles: space, no_space 134 | # SupportedStylesForEmptyBraces: space, no_space 135 | Layout/SpaceBeforeBlockBraces: 136 | Exclude: 137 | - 'mail_room.gemspec' 138 | - 'spec/lib/crash_handler_spec.rb' 139 | - 'spec/lib/mailbox_spec.rb' 140 | 141 | # Offense count: 51 142 | # This cop supports safe autocorrection (--autocorrect). 143 | # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters. 144 | # SupportedStyles: space, no_space 145 | # SupportedStylesForEmptyBraces: space, no_space 146 | Layout/SpaceInsideBlockBraces: 147 | Exclude: 148 | - 'lib/mail_room/coordinator.rb' 149 | - 'spec/lib/cli_spec.rb' 150 | - 'spec/lib/configuration_spec.rb' 151 | - 'spec/lib/delivery/letter_opener_spec.rb' 152 | - 'spec/lib/delivery/logger_spec.rb' 153 | - 'spec/lib/delivery/que_spec.rb' 154 | - 'spec/lib/imap/connection_spec.rb' 155 | - 'spec/lib/mailbox_watcher_spec.rb' 156 | 157 | # Offense count: 32 158 | # This cop supports safe autocorrection (--autocorrect). 159 | # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces. 160 | # SupportedStyles: space, no_space, compact 161 | # SupportedStylesForEmptyBraces: space, no_space 162 | Layout/SpaceInsideHashLiteralBraces: 163 | Exclude: 164 | - 'lib/mail_room/mailbox.rb' 165 | - 'spec/lib/cli_spec.rb' 166 | - 'spec/lib/mailbox_spec.rb' 167 | 168 | # Offense count: 1 169 | # This cop supports safe autocorrection (--autocorrect). 170 | # Configuration parameters: EnforcedStyle. 171 | # SupportedStyles: space, compact, no_space 172 | Layout/SpaceInsideParens: 173 | Exclude: 174 | - 'spec/lib/logger/structured_spec.rb' 175 | 176 | # Offense count: 1 177 | # This cop supports safe autocorrection (--autocorrect). 178 | # Configuration parameters: EnforcedStyle. 179 | # SupportedStyles: final_newline, final_blank_line 180 | Layout/TrailingEmptyLines: 181 | Exclude: 182 | - 'spec/lib/delivery/letter_opener_spec.rb' 183 | 184 | # Offense count: 4 185 | # This cop supports safe autocorrection (--autocorrect). 186 | # Configuration parameters: AllowInHeredoc. 187 | Layout/TrailingWhitespace: 188 | Exclude: 189 | - 'lib/mail_room/coordinator.rb' 190 | - 'lib/mail_room/imap.rb' 191 | - 'spec/lib/coordinator_spec.rb' 192 | 193 | # Offense count: 5 194 | # Configuration parameters: AllowedMethods. 195 | # AllowedMethods: enums 196 | Lint/ConstantDefinitionInBlock: 197 | Exclude: 198 | - 'lib/mail_room/mailbox.rb' 199 | 200 | # Offense count: 1 201 | Lint/RescueException: 202 | Exclude: 203 | - 'lib/mail_room/cli.rb' 204 | 205 | # Offense count: 1 206 | # This cop supports safe autocorrection (--autocorrect). 207 | # Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods. 208 | Lint/UnusedMethodArgument: 209 | Exclude: 210 | - 'lib/mail_room/logger/structured.rb' 211 | 212 | # Offense count: 5 213 | # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. 214 | Metrics/AbcSize: 215 | Max: 27 216 | 217 | # Offense count: 32 218 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 219 | # AllowedMethods: refine 220 | Metrics/BlockLength: 221 | Max: 198 222 | 223 | # Offense count: 3 224 | # Configuration parameters: CountComments, CountAsOne. 225 | Metrics/ClassLength: 226 | Max: 169 227 | 228 | # Offense count: 1 229 | # Configuration parameters: AllowedMethods, AllowedPatterns. 230 | Metrics/CyclomaticComplexity: 231 | Max: 8 232 | 233 | # Offense count: 13 234 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 235 | Metrics/MethodLength: 236 | Max: 22 237 | 238 | # Offense count: 1 239 | # Configuration parameters: CountComments, CountAsOne. 240 | Metrics/ModuleLength: 241 | Max: 172 242 | 243 | # Offense count: 1 244 | Naming/AccessorMethodName: 245 | Exclude: 246 | - 'lib/mail_room/configuration.rb' 247 | 248 | # Offense count: 1 249 | # This cop supports unsafe autocorrection (--autocorrect-all). 250 | Security/YAMLLoad: 251 | Exclude: 252 | - 'lib/mail_room/configuration.rb' 253 | 254 | # Offense count: 1 255 | # This cop supports safe autocorrection (--autocorrect). 256 | # Configuration parameters: EnforcedStyle. 257 | # SupportedStyles: prefer_alias, prefer_alias_method 258 | Style/Alias: 259 | Exclude: 260 | - 'lib/mail_room/coordinator.rb' 261 | 262 | # Offense count: 16 263 | # This cop supports safe autocorrection (--autocorrect). 264 | # Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, AllowBracesOnProceduralOneLiners, BracesRequiredMethods. 265 | # SupportedStyles: line_count_based, semantic, braces_for_chaining, always_braces 266 | # ProceduralMethods: benchmark, bm, bmbm, create, each_with_object, measure, new, realtime, tap, with_object 267 | # FunctionalMethods: let, let!, subject, watch 268 | # AllowedMethods: lambda, proc, it 269 | Style/BlockDelimiters: 270 | Exclude: 271 | - 'spec/lib/arbitration/redis_spec.rb' 272 | - 'spec/lib/delivery/que_spec.rb' 273 | - 'spec/lib/delivery/sidekiq_spec.rb' 274 | 275 | # Offense count: 14 276 | # Configuration parameters: AllowedConstants. 277 | Style/Documentation: 278 | Exclude: 279 | - 'spec/**/*' 280 | - 'test/**/*' 281 | - 'lib/mail_room.rb' 282 | - 'lib/mail_room/arbitration.rb' 283 | - 'lib/mail_room/arbitration/noop.rb' 284 | - 'lib/mail_room/arbitration/redis.rb' 285 | - 'lib/mail_room/connection.rb' 286 | - 'lib/mail_room/crash_handler.rb' 287 | - 'lib/mail_room/delivery.rb' 288 | - 'lib/mail_room/imap.rb' 289 | - 'lib/mail_room/imap/connection.rb' 290 | - 'lib/mail_room/imap/message.rb' 291 | - 'lib/mail_room/logger/structured.rb' 292 | - 'lib/mail_room/message.rb' 293 | - 'lib/mail_room/microsoft_graph.rb' 294 | - 'lib/mail_room/microsoft_graph/connection.rb' 295 | 296 | # Offense count: 2 297 | # This cop supports safe autocorrection (--autocorrect). 298 | # Configuration parameters: EnforcedStyle. 299 | # SupportedStyles: compact, expanded 300 | Style/EmptyMethod: 301 | Exclude: 302 | - 'lib/mail_room/arbitration/noop.rb' 303 | - 'lib/mail_room/delivery/noop.rb' 304 | 305 | # Offense count: 1 306 | # This cop supports safe autocorrection (--autocorrect). 307 | Style/Encoding: 308 | Exclude: 309 | - 'mail_room.gemspec' 310 | 311 | # Offense count: 2 312 | # This cop supports safe autocorrection (--autocorrect). 313 | Style/ExpandPathArguments: 314 | Exclude: 315 | - 'mail_room.gemspec' 316 | - 'spec/spec_helper.rb' 317 | 318 | # Offense count: 39 319 | # This cop supports unsafe autocorrection (--autocorrect-all). 320 | # Configuration parameters: EnforcedStyle. 321 | # SupportedStyles: always, always_true, never 322 | Style/FrozenStringLiteralComment: 323 | Enabled: false 324 | 325 | # Offense count: 5 326 | # This cop supports unsafe autocorrection (--autocorrect-all). 327 | Style/GlobalStdStream: 328 | Exclude: 329 | - 'lib/mail_room/crash_handler.rb' 330 | - 'lib/mail_room/delivery/logger.rb' 331 | - 'lib/mail_room/mailbox.rb' 332 | - 'spec/lib/delivery/logger_spec.rb' 333 | 334 | # Offense count: 3 335 | # This cop supports safe autocorrection (--autocorrect). 336 | # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. 337 | Style/GuardClause: 338 | Exclude: 339 | - 'lib/mail_room/configuration.rb' 340 | - 'lib/mail_room/imap/connection.rb' 341 | - 'lib/mail_room/mailbox_watcher.rb' 342 | 343 | # Offense count: 1 344 | # This cop supports safe autocorrection (--autocorrect). 345 | # Configuration parameters: EnforcedStyle, EnforcedShorthandSyntax, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. 346 | # SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys 347 | # SupportedShorthandSyntax: always, never, either, consistent 348 | Style/HashSyntax: 349 | Exclude: 350 | - 'spec/lib/microsoft_graph/connection_spec.rb' 351 | 352 | # Offense count: 1 353 | # This cop supports safe autocorrection (--autocorrect). 354 | Style/IfUnlessModifier: 355 | Exclude: 356 | - 'lib/mail_room/mailbox_watcher.rb' 357 | 358 | # Offense count: 5 359 | # This cop supports unsafe autocorrection (--autocorrect-all). 360 | # Configuration parameters: EnforcedStyle. 361 | # SupportedStyles: literals, strict 362 | Style/MutableConstant: 363 | Exclude: 364 | - 'lib/mail_room/crash_handler.rb' 365 | - 'lib/mail_room/mailbox.rb' 366 | - 'lib/mail_room/version.rb' 367 | - 'spec/spec_helper.rb' 368 | 369 | # Offense count: 1 370 | # This cop supports unsafe autocorrection (--autocorrect-all). 371 | # Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns. 372 | # SupportedStyles: predicate, comparison 373 | Style/NumericPredicate: 374 | Exclude: 375 | - 'spec/**/*' 376 | - 'lib/mail_room/imap/connection.rb' 377 | 378 | # Offense count: 2 379 | # This cop supports unsafe autocorrection (--autocorrect-all). 380 | # Configuration parameters: EnforcedStyle. 381 | # SupportedStyles: short, verbose 382 | Style/PreferredHashMethods: 383 | Exclude: 384 | - 'lib/mail_room/configuration.rb' 385 | - 'lib/mail_room/mailbox.rb' 386 | 387 | # Offense count: 1 388 | # This cop supports safe autocorrection (--autocorrect). 389 | # Configuration parameters: EnforcedStyle, AllowedCompactTypes. 390 | # SupportedStyles: compact, exploded 391 | Style/RaiseArgs: 392 | Exclude: 393 | - 'lib/mail_room/logger/structured.rb' 394 | 395 | # Offense count: 2 396 | # This cop supports safe autocorrection (--autocorrect). 397 | Style/RedundantPercentQ: 398 | Exclude: 399 | - 'mail_room.gemspec' 400 | 401 | # Offense count: 7 402 | # This cop supports safe autocorrection (--autocorrect). 403 | Style/RedundantSelf: 404 | Exclude: 405 | - 'lib/mail_room/configuration.rb' 406 | - 'lib/mail_room/coordinator.rb' 407 | - 'lib/mail_room/mailbox.rb' 408 | - 'lib/mail_room/mailbox_watcher.rb' 409 | 410 | # Offense count: 1 411 | # This cop supports safe autocorrection (--autocorrect). 412 | # Configuration parameters: EnforcedStyle. 413 | # SupportedStyles: implicit, explicit 414 | Style/RescueStandardError: 415 | Exclude: 416 | - 'lib/mail_room/configuration.rb' 417 | 418 | # Offense count: 1 419 | # This cop supports unsafe autocorrection (--autocorrect-all). 420 | # Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. 421 | # AllowedMethods: present?, blank?, presence, try, try! 422 | Style/SafeNavigation: 423 | Exclude: 424 | - 'lib/mail_room/mailbox_watcher.rb' 425 | 426 | # Offense count: 1 427 | # This cop supports unsafe autocorrection (--autocorrect-all). 428 | # Configuration parameters: RequireEnglish, EnforcedStyle. 429 | # SupportedStyles: use_perl_names, use_english_names, use_builtin_english_names 430 | Style/SpecialGlobalVars: 431 | Exclude: 432 | - 'mail_room.gemspec' 433 | 434 | # Offense count: 1 435 | # This cop supports unsafe autocorrection (--autocorrect-all). 436 | # Configuration parameters: Mode. 437 | Style/StringConcatenation: 438 | Exclude: 439 | - 'lib/mail_room/logger/structured.rb' 440 | 441 | # Offense count: 142 442 | # This cop supports safe autocorrection (--autocorrect). 443 | # Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. 444 | # SupportedStyles: single_quotes, double_quotes 445 | Style/StringLiterals: 446 | Enabled: false 447 | 448 | # Offense count: 4 449 | # This cop supports safe autocorrection (--autocorrect). 450 | # Configuration parameters: EnforcedStyle, MinSize. 451 | # SupportedStyles: percent, brackets 452 | Style/SymbolArray: 453 | Exclude: 454 | - 'lib/mail_room/mailbox.rb' 455 | - 'spec/lib/logger/structured_spec.rb' 456 | 457 | # Offense count: 2 458 | # This cop supports safe autocorrection (--autocorrect). 459 | # Configuration parameters: EnforcedStyleForMultiline. 460 | # SupportedStylesForMultiline: comma, consistent_comma, no_comma 461 | Style/TrailingCommaInHashLiteral: 462 | Exclude: 463 | - 'spec/lib/mailbox_spec.rb' 464 | - 'spec/spec_helper.rb' 465 | 466 | # Offense count: 1 467 | # This cop supports safe autocorrection (--autocorrect). 468 | Style/WhileUntilDo: 469 | Exclude: 470 | - 'lib/mail_room/mailbox_watcher.rb' 471 | 472 | # Offense count: 2 473 | # This cop supports safe autocorrection (--autocorrect). 474 | Style/WhileUntilModifier: 475 | Exclude: 476 | - 'lib/mail_room/coordinator.rb' 477 | - 'lib/mail_room/mailbox_watcher.rb' 478 | 479 | # Offense count: 3 480 | # This cop supports safe autocorrection (--autocorrect). 481 | # Configuration parameters: WordRegex. 482 | # SupportedStyles: percent, brackets 483 | Style/WordArray: 484 | EnforcedStyle: percent 485 | MinSize: 3 486 | 487 | # Offense count: 7 488 | # This cop supports safe autocorrection (--autocorrect). 489 | # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. 490 | # URISchemes: http, https 491 | Layout/LineLength: 492 | Max: 177 493 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mail_room # 2 | 3 | mail_room is a configuration based process that will listen for incoming 4 | e-mail and execute a delivery method when a new message is 5 | received. mail_room supports the following methods for receiving e-mail: 6 | 7 | * IMAP 8 | * [Microsoft Graph API](https://docs.microsoft.com/en-us/graph/api/resources/mail-api-overview?view=graph-rest-1.0) 9 | 10 | Examples of delivery methods include: 11 | 12 | * POST to a delivery URL (Postback) 13 | * Queue a job to Sidekiq or Que for later processing (Sidekiq or Que) 14 | * Log the message or open with LetterOpener (Logger or LetterOpener) 15 | 16 | [![Build Status](https://travis-ci.org/tpitale/mail_room.png?branch=master)](https://travis-ci.org/tpitale/mail_room) 17 | [![Code Climate](https://codeclimate.com/github/tpitale/mail_room/badges/gpa.svg)](https://codeclimate.com/github/tpitale/mail_room) 18 | 19 | ## Installation ## 20 | 21 | Add this line to your application's Gemfile: 22 | 23 | gem 'mail_room' 24 | 25 | And then execute: 26 | 27 | $ bundle 28 | 29 | Or install it yourself as: 30 | 31 | $ gem install mail_room 32 | 33 | You will also need to install `faraday` or `letter_opener` if you use the `postback` or `letter_opener` delivery methods, respectively. 34 | 35 | ## Usage ## 36 | 37 | mail_room -c /path/to/config.yml 38 | 39 | **Note:** To ignore missing config file or missing `mailboxes` key, use `-q` or `--quiet` 40 | 41 | ## Configuration ## 42 | 43 | ```yaml 44 | --- 45 | :mailboxes: 46 | - 47 | :email: "user1@gmail.com" 48 | :password: "password" 49 | :name: "inbox" 50 | :search_command: 'NEW' 51 | :logger: 52 | :log_path: /path/to/logfile/for/mailroom 53 | :delivery_options: 54 | :delivery_url: "http://localhost:3000/inbox" 55 | :delivery_token: "abcdefg" 56 | :content_type: "text/plain" 57 | 58 | - 59 | :email: "user2@gmail.com" 60 | :password: "password" 61 | :name: "inbox" 62 | :delivery_method: postback 63 | :delivery_options: 64 | :delivery_url: "http://localhost:3000/inbox" 65 | :delivery_token: "abcdefg" 66 | - 67 | :email: "user3@gmail.com" 68 | :password: "password" 69 | :name: "inbox" 70 | :delivery_method: logger 71 | :delivery_options: 72 | :log_path: "/var/log/user3-email.log" 73 | - 74 | :email: "user4@gmail.com" 75 | :password: "password" 76 | :name: "inbox" 77 | :delivery_method: letter_opener 78 | :delete_after_delivery: true 79 | :expunge_deleted: true 80 | :delivery_options: 81 | :location: "/tmp/user4-email" 82 | - 83 | :email: "user5@gmail.com" 84 | :password: "password" 85 | :name: "inbox" 86 | :delivery_method: sidekiq 87 | :delivery_options: 88 | :redis_url: redis://localhost:6379 89 | :worker: EmailReceiverWorker 90 | - 91 | :email: "user6@gmail.com" 92 | :password: "password" 93 | :name: "inbox" 94 | :delivery_method: sidekiq 95 | :delivery_options: 96 | # When pointing to sentinel, follow this sintax for redis URLs: 97 | # redis://:@/ 98 | :redis_url: redis://:password@my-redis-sentinel/ 99 | :sentinels: 100 | - 101 | :host: 127.0.0.1 102 | :port: 26379 103 | :worker: EmailReceiverWorker 104 | - 105 | :email: "user7@outlook365.com" 106 | :password: "password" 107 | :name: "inbox" 108 | :inbox_method: microsoft_graph 109 | :inbox_options: 110 | :tenant_id: 12345 111 | :client_id: ABCDE 112 | :client_secret: YOUR-SECRET-HERE 113 | :poll_interval: 60 114 | :azure_ad_endpoint: https://login.microsoftonline.com 115 | :graph_endpoint: https://graph.microsoft.com 116 | :delivery_method: sidekiq 117 | :delivery_options: 118 | :redis_url: redis://localhost:6379 119 | :worker: EmailReceiverWorker 120 | - 121 | :email: "user8@gmail.com" 122 | :password: "password" 123 | :name: "inbox" 124 | :delivery_method: postback 125 | :delivery_options: 126 | :delivery_url: "http://localhost:3000/inbox" 127 | :jwt_auth_header: "Mailroom-Api-Request" 128 | :jwt_issuer: "mailroom" 129 | :jwt_algorithm: "HS256" 130 | :jwt_secret_path: "/etc/secrets/mailroom/.mailroom_secret" 131 | ``` 132 | 133 | **Note:** If using `delete_after_delivery`, you also probably want to use 134 | `expunge_deleted` unless you really know what you're doing. 135 | 136 | ## inbox_method 137 | 138 | By default, IMAP mode is assumed for reading a mailbox. 139 | 140 | ### IMAP Server Configuration ## 141 | 142 | You can set per-mailbox configuration for the IMAP server's `host` (default: 'imap.gmail.com'), `port` (default: 993), `ssl` (default: true), and `start_tls` (default: false). 143 | 144 | If you want to set additional options for IMAP SSL you can pass a YAML hash to match [SSLContext#set_params](http://docs.ruby-lang.org/en/2.2.0/OpenSSL/SSL/SSLContext.html#method-i-set_params). If you set `verify_mode` to `:none` it'll replace with the appropriate constant. 145 | 146 | If you're seeing the error `Please log in via your web browser: https://support.google.com/mail/accounts/answer/78754 (Failure)`, you need to configure your Gmail account to allow less secure apps to access it: https://support.google.com/accounts/answer/6010255. 147 | 148 | ### Microsoft Graph configuration 149 | 150 | To use the Microsoft Graph API instead of IMAP to read e-mail, you will 151 | need to create an application in Microsoft Entra ID (formerly known as Azure Active Directory). See the 152 | [Microsoft instructions](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) for more details: 153 | 154 | 1. Sign in to the [Azure portal](https://portal.azure.com). 155 | 1. Search for and select `Microsoft Entra ID`. 156 | 1. Under `Manage`, select `App registrations` > `New registration`. 157 | 1. Enter a `Name` for your application, such as `MailRoom`. Users of your app might see this name, and you can change it later. 158 | 1. If `Supported account types` is listed, select the appropriate option. 159 | 1. Leave `Redirect URI` blank. This is not needed. 160 | 1. Select `Register`. 161 | 1. Under `Manage`, select `Certificates & secrets`. 162 | 1. Under `Client secrets`, select `New client secret`, and enter a name. 163 | 1. Under `Expires`, select `Never`, unless you plan on updating the credentials every time it expires. 164 | 1. Select `Add`. Record the secret value in a safe location for use in a later step. 165 | 1. Under `Manage`, select `API Permissions` > `Add a permission`. Select `Microsoft Graph`. 166 | 1. Select `Application permissions`. 167 | 1. Under the `Mail` node, select `Mail.ReadWrite`, and then select Add permissions. 168 | 1. If `User.Read` is listed in the permission list, you can delete this. 169 | 1. Click `Grant admin consent` for these permissions. 170 | 171 | #### Restrict mailbox access 172 | 173 | Note that for MailRoom to work as a service account, this application 174 | must have the `Mail.ReadWrite` to read/write mail in *all* 175 | mailboxes. However, while this appears to be security risk, 176 | we can configure an application access policy to limit the 177 | mailbox access for this account. [Follow these instructions](https://docs.microsoft.com/en-us/graph/auth-limit-mailbox-access) 178 | to setup PowerShell and configure this policy. 179 | 180 | #### MailRoom config for Microsoft Graph 181 | 182 | In the MailRoom configuration, set `inbox_method` to `microsoft_graph`. 183 | You will also need: 184 | 185 | * The client and tenant ID from the `Overview` section in the Azure app page 186 | * The client secret created earlier 187 | 188 | Fill in `inbox_options` with these values: 189 | 190 | ```yaml 191 | :inbox_method: microsoft_graph 192 | :inbox_options: 193 | :tenant_id: 12345 194 | :client_id: ABCDE 195 | :client_secret: YOUR-SECRET-HERE 196 | :poll_interval: 60 197 | ``` 198 | 199 | By default, MailRoom will poll for new messages every 60 seconds. `poll_interval` configures the number of 200 | seconds to poll. Setting the value to 0 or under will default to 60 seconds. 201 | 202 | ### Alternative Azure cloud deployments 203 | 204 | MailRoom will default to using the standard Azure HTTPS endpoints. To 205 | configure MailRoom with Microsoft Cloud for US Government or other 206 | [national cloud deployments](https://docs.microsoft.com/en-us/graph/deployments), set 207 | the `azure_ad_endpoint` and `graph_endpoint` accordingly. For example, 208 | for Microsoft Cloud for US Government: 209 | 210 | ```yaml 211 | :inbox_method: microsoft_graph 212 | :inbox_options: 213 | :tenant_id: 12345 214 | :client_id: ABCDE 215 | :client_secret: YOUR-SECRET-HERE 216 | :poll_interval: 60 217 | :azure_ad_endpoint: https://login.microsoftonline.us 218 | :graph_endpoint: https://graph.microsoft.us 219 | ``` 220 | 221 | ## delivery_method ## 222 | 223 | ### postback ### 224 | 225 | Requires `faraday` gem be installed. 226 | 227 | *NOTE:* If you're using Ruby `>= 2.0`, you'll need to use Faraday from `>= 0.8.9`. Versions before this seem to have some weird behavior with `mail_room`. 228 | 229 | The default delivery method, requires `delivery_url` and `delivery_token` in 230 | configuration. 231 | 232 | You can pass `content_type:` option to overwrite `faraday's` default content-type(`application/x-www-form-urlencoded`) for post requests, we recommend passing `text/plain` as content-type. 233 | 234 | As the postback is essentially using your app as if it were an API endpoint, 235 | you may need to disable forgery protection as you would with a JSON API. 236 | 237 | ### sidekiq ### 238 | 239 | Deliver the message by pushing it onto the configured Sidekiq queue to be handled by a custom worker. 240 | 241 | Requires `redis` gem to be installed. 242 | 243 | Configured with `:delivery_method: sidekiq`. 244 | 245 | Delivery options: 246 | - **redis_url**: The Redis server to connect with. Use the same Redis URL that's used to configure Sidekiq. 247 | Required, defaults to `redis://localhost:6379`. 248 | - **sentinels**: A list of sentinels servers used to provide HA to Redis. (see [Sentinel Support](#sentinel-support)) 249 | Optional. 250 | - **namespace**: [DEPRECATED] The Redis namespace Sidekiq works under. Use the same Redis namespace that's used to configure Sidekiq. 251 | Optional. 252 | - **queue**: The Sidekiq queue the job is pushed onto. Make sure Sidekiq actually reads off this queue. 253 | Required, defaults to `default`. 254 | - **worker**: The worker class that will handle the message. 255 | Required. 256 | 257 | An example worker implementation looks like this: 258 | 259 | 260 | ```ruby 261 | class EmailReceiverWorker 262 | include Sidekiq::Worker 263 | 264 | def perform(message) 265 | mail = Mail::Message.new(message) 266 | 267 | puts "New mail from #{mail.from.first}: #{mail.subject}" 268 | end 269 | end 270 | ``` 271 | 272 | ### que ### 273 | 274 | Deliver the message by pushing it onto the configured Que queue to be handled by a custom worker. 275 | 276 | Requires `pg` gem to be installed. 277 | 278 | Configured with `:delivery_method: que`. 279 | 280 | Delivery options: 281 | - **host**: The postgresql server host to connect with. Use the database you use with Que. 282 | Required, defaults to `localhost`. 283 | - **port**: The postgresql server port to connect with. Use the database you use with Que. 284 | Required, defaults to `5432`. 285 | - **database**: The postgresql database to use. Use the database you use with Que. 286 | Required. 287 | - **queue**: The Que queue the job is pushed onto. Make sure Que actually reads off this queue. 288 | Required, defaults to `default`. 289 | - **job_class**: The worker class that will handle the message. 290 | Required. 291 | - **priority**: The priority you want this job to run at. 292 | Required, defaults to `100`, lowest Que default priority. 293 | 294 | An example worker implementation looks like this: 295 | 296 | ```ruby 297 | class EmailReceiverJob < Que::Job 298 | def run(message) 299 | mail = Mail::Message.new(message) 300 | 301 | puts "New mail from #{mail.from.first}: #{mail.subject}" 302 | end 303 | end 304 | ``` 305 | 306 | ### logger ### 307 | 308 | Configured with `:delivery_method: logger`. 309 | 310 | If the `:log_path:` delivery option is not provided, defaults to `STDOUT` 311 | 312 | ### noop ### 313 | 314 | Configured with `:delivery_method: noop`. 315 | 316 | Does nothing, like it says. 317 | 318 | ### letter_opener ### 319 | 320 | Requires `letter_opener` gem be installed. 321 | 322 | Configured with `:delivery_method: letter_opener`. 323 | 324 | Uses Ryan Bates' excellent [letter_opener](https://github.com/ryanb/letter_opener) gem. 325 | 326 | ## ActionMailbox in Rails ## 327 | 328 | MailRoom can deliver mail to Rails using the ActionMailbox [configuration options for an SMTP relay](https://edgeguides.rubyonrails.org/action_mailbox_basics.html#configuration). 329 | 330 | In summary (from the ActionMailbox docs) 331 | 332 | 1. Configure Rails to use the `:relay` ingress option: 333 | ```rb 334 | # config/environments/production.rb 335 | config.action_mailbox.ingress = :relay 336 | ``` 337 | 338 | 2. Generate a strong password (e.g., using SecureRandom or something) and add it to Rails config: 339 | using `rails credentials:edit` under `action_mailbox.ingress_password`. 340 | 341 | And finally, configure MailRoom to use the postback configuration with the options: 342 | 343 | ```yaml 344 | :delivery_method: postback 345 | :delivery_options: 346 | :delivery_url: https://example.com/rails/action_mailbox/relay/inbound_emails 347 | :username: actionmailbox 348 | :password: 349 | ``` 350 | 351 | ## Receiving `postback` in Rails ## 352 | 353 | If you have a controller that you're sending to, with forgery protection 354 | disabled, you can get the raw string of the email using `request.body.read`. 355 | 356 | I would recommend having the `mail` gem bundled and parse the email using 357 | `Mail.read_from_string(request.body.read)`. 358 | 359 | *Note:* If you get the exception (`Rack::QueryParser::InvalidParameterError (invalid %-encoding...`) 360 | it's probably because the content-type is set to Faraday's default, which is `HEADERS['content-type'] = 'application/x-www-form-urlencoded'`. It can cause `Rack` to crash due to `InvalidParameterError` exception. When you send a post with `application/x-www-form-urlencoded`, `Rack` will attempt to parse the input and can end up raising an exception, for example if the email that you are forwarding contain `%%` in its content or headers it will cause Rack to crash with the message above. 361 | 362 | ## idle_timeout ## 363 | 364 | By default, the IDLE command will wait for 29 minutes (in order to keep the server connection happy). 365 | If you'd prefer not to wait that long, you can pass `idle_timeout` in seconds for your mailbox configuration. 366 | 367 | ## Search Command ## 368 | 369 | This setting allows configuration of the IMAP search command sent to the server. This still defaults 'UNSEEN'. You may find that 'NEW' works better for you. 370 | 371 | ## Running in Production ## 372 | 373 | I suggest running with either upstart or init.d. Check out this wiki page for some example scripts for both: https://github.com/tpitale/mail_room/wiki/Init-Scripts-for-Running-mail_room 374 | 375 | ## Arbitration ## 376 | 377 | When running multiple instances of MailRoom against a single mailbox, to try to prevent delivery of the same message multiple times, we can configure Arbitration using Redis. 378 | 379 | ```yaml 380 | :mailboxes: 381 | - 382 | :email: "user1@gmail.com" 383 | :password: "password" 384 | :name: "inbox" 385 | :delivery_method: postback 386 | :delivery_options: 387 | :delivery_url: "http://localhost:3000/inbox" 388 | :delivery_token: "abcdefg" 389 | 390 | :arbitration_method: redis 391 | :arbitration_options: 392 | # The Redis server to connect with. Defaults to redis://localhost:6379. 393 | :redis_url: redis://redis.example.com:6379 394 | # [DEPRECATED] The Redis namespace to house the Redis keys under. Optional. 395 | :namespace: mail_room 396 | - 397 | :email: "user2@gmail.com" 398 | :password: "password" 399 | :name: "inbox" 400 | :delivery_method: postback 401 | :delivery_options: 402 | :delivery_url: "http://localhost:3000/inbox" 403 | :delivery_token: "abcdefg" 404 | 405 | :arbitration_method: redis 406 | :arbitration_options: 407 | # When pointing to sentinel, follow this sintax for redis URLs: 408 | # redis://:@/ 409 | :redis_url: redis://:password@my-redis-sentinel/ 410 | :sentinels: 411 | - 412 | :host: 127.0.0.1 413 | :port: 26379 414 | # [DEPRECATED] The Redis namespace to house the Redis keys under. Optional. 415 | :namespace: mail_room 416 | ``` 417 | 418 | **Note:** This will likely never be a _perfect_ system for preventing multiple deliveries of the same message, so I would advise checking the unique `message_id` if you are running in this situation. 419 | 420 | **Note:** There are other scenarios for preventing duplication of messages at scale that _may_ be more appropriate in your particular setup. One such example is using multiple inboxes in reply-by-email situations. Another is to use labels and configure a different `SEARCH` command for each instance of MailRoom. 421 | 422 | ## Sentinel Support 423 | 424 | Redis Sentinel provides high availability for Redis. Please read their [documentation](http://redis.io/topics/sentinel) 425 | first, before enabling it with mail_room. 426 | 427 | To connect to a Sentinel, you need to setup authentication to both sentinels and redis daemons first, and make sure 428 | both are binding to a reachable IP address. 429 | 430 | In mail_room, when you are connecting to a Sentinel, you have to inform the `master-name` and the `password` through 431 | `redis_url` param, following this syntax: 432 | 433 | ``` 434 | redis://:@/ 435 | ``` 436 | 437 | You also have to inform at least one pair of `host` and `port` for a sentinel in your cluster. 438 | To have a minimum reliable setup, you need at least `3` sentinel nodes and `3` redis servers (1 master, 2 slaves). 439 | 440 | ## Logging ## 441 | 442 | MailRoom will output JSON-formatted logs to give some observability into its operations. 443 | 444 | Simply configure a `log_path` for the `logger` on any of your mailboxes. By default, nothing will be logged. 445 | 446 | If you wish to log to `STDOUT` or `STDERR` instead of a file, you can pass `:stdout` or `:stderr`, 447 | respectively and MailRoom will log there. 448 | 449 | ## Contributing ## 450 | 451 | 1. Fork it 452 | 2. Create your feature branch (`git checkout -b my-new-feature`) 453 | 3. Commit your changes (`git commit -am 'Add some feature'`) 454 | 4. Push to the branch (`git push origin my-new-feature`) 455 | 5. Create new Pull Request 456 | 6. If accepted, ask for commit rights 457 | --------------------------------------------------------------------------------