gitlab-mail_room 0.0.10 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,7 @@ module MailRoom
31
31
  # Any subsequent failure in the instance which gets the lock will be dealt
32
32
  # with by the expiration, at which time another instance can pick up the
33
33
  # message and try again.
34
- client.set(key, 1, {:nx => true, :ex => expiration})
34
+ client.set(key, 1, {nx: true, ex: expiration})
35
35
  end
36
36
 
37
37
  private
@@ -16,8 +16,6 @@ module MailRoom
16
16
  raise NotImplementedError
17
17
  end
18
18
 
19
- def quit
20
- raise NotImplementedError
21
- end
19
+ def quit; end
22
20
  end
23
21
  end
@@ -24,7 +24,7 @@ module MailRoom
24
24
  # Trigger `LetterOpener` to deliver our message
25
25
  # @param message [String] the email message as a string, RFC822 format
26
26
  def deliver(message)
27
- method = ::LetterOpener::DeliveryMethod.new(:location => @delivery_options.location)
27
+ method = ::LetterOpener::DeliveryMethod.new(location: @delivery_options.location)
28
28
  method.deliver!(Mail.read_from_string(message))
29
29
 
30
30
  true
@@ -8,16 +8,17 @@ module MailRoom
8
8
  # Sidekiq Delivery method
9
9
  # @author Douwe Maan
10
10
  class Sidekiq
11
- Options = Struct.new(:redis_url, :namespace, :sentinels, :queue, :worker, :logger) do
11
+ Options = Struct.new(:redis_url, :namespace, :sentinels, :queue, :worker, :logger, :redis_db) do
12
12
  def initialize(mailbox)
13
13
  redis_url = mailbox.delivery_options[:redis_url] || "redis://localhost:6379"
14
+ redis_db = mailbox.delivery_options[:redis_db] || 0
14
15
  namespace = mailbox.delivery_options[:namespace]
15
16
  sentinels = mailbox.delivery_options[:sentinels]
16
17
  queue = mailbox.delivery_options[:queue] || "default"
17
18
  worker = mailbox.delivery_options[:worker]
18
19
  logger = mailbox.logger
19
20
 
20
- super(redis_url, namespace, sentinels, queue, worker, logger)
21
+ super(redis_url, namespace, sentinels, queue, worker, logger, redis_db)
21
22
  end
22
23
  end
23
24
 
@@ -45,7 +46,7 @@ module MailRoom
45
46
  def client
46
47
  @client ||= begin
47
48
  sentinels = options.sentinels
48
- redis_options = { url: options.redis_url }
49
+ redis_options = { url: options.redis_url, db: options.redis_db }
49
50
  redis_options[:sentinels] = sentinels if sentinels
50
51
 
51
52
  redis = ::Redis.new(redis_options)
@@ -1,11 +1,14 @@
1
1
  require "mail_room/delivery"
2
2
  require "mail_room/arbitration"
3
3
  require "mail_room/imap"
4
+ require "mail_room/microsoft_graph"
4
5
 
5
6
  module MailRoom
6
7
  # Mailbox Configuration fields
7
8
  MAILBOX_FIELDS = [
8
9
  :email,
10
+ :inbox_method,
11
+ :inbox_options,
9
12
  :password,
10
13
  :host,
11
14
  :port,
@@ -42,24 +45,26 @@ module MailRoom
42
45
  # 29 minutes, as suggested by the spec: https://tools.ietf.org/html/rfc2177
43
46
  IMAP_IDLE_TIMEOUT = 29 * 60 # 29 minutes in in seconds
44
47
 
45
- REQUIRED_CONFIGURATION = [:name, :email, :password, :host, :port]
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
46
51
 
47
52
  # Default attributes for the mailbox configuration
48
53
  DEFAULTS = {
49
- :search_command => 'UNSEEN',
50
- :delivery_method => 'postback',
51
- :host => 'imap.gmail.com',
52
- :port => 993,
53
- :ssl => true,
54
- :start_tls => false,
55
- :limit_max_unread => 0,
56
- :idle_timeout => IMAP_IDLE_TIMEOUT,
57
- :delete_after_delivery => false,
58
- :expunge_deleted => false,
59
- :delivery_options => {},
60
- :arbitration_method => 'noop',
61
- :arbitration_options => {},
62
- :logger => {}
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: {}
63
68
  }
64
69
 
65
70
  # Store the configuration and require the appropriate delivery method
@@ -122,14 +127,32 @@ module MailRoom
122
127
  { email: self.email, name: self.name }
123
128
  end
124
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
+
125
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!
126
149
  if self[:idle_timeout] > IMAP_IDLE_TIMEOUT
127
150
  raise IdleTimeoutTooLarge,
128
151
  "Please use an idle timeout smaller than #{29*60} to prevent " \
129
152
  "IMAP server disconnects"
130
153
  end
131
154
 
132
- REQUIRED_CONFIGURATION.each do |k|
155
+ IMAP_CONFIGURATION.each do |k|
133
156
  if self[k].nil?
134
157
  raise ConfigurationError,
135
158
  "Field :#{k} is required in Mailbox: #{inspect}"
@@ -137,7 +160,23 @@ module MailRoom
137
160
  end
138
161
  end
139
162
 
140
- private
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
141
180
 
142
181
  def parsed_arbitration_options
143
182
  arbitration_klass::Options.new(self)
@@ -62,8 +62,14 @@ module MailRoom
62
62
  end
63
63
 
64
64
  private
65
+
65
66
  def connection
66
- @connection ||= ::MailRoom::IMAP::Connection.new(@mailbox)
67
+ @connection ||=
68
+ if @mailbox.microsoft_graph?
69
+ ::MailRoom::MicrosoftGraph::Connection.new(@mailbox)
70
+ else
71
+ ::MailRoom::IMAP::Connection.new(@mailbox)
72
+ end
67
73
  end
68
74
  end
69
75
  end
@@ -0,0 +1,217 @@
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
+ SCOPE = 'https://graph.microsoft.com/.default'
10
+ NEXT_PAGE_KEY = '@odata.nextLink'
11
+ DEFAULT_POLL_INTERVAL_S = 60
12
+
13
+ TooManyRequestsError = Class.new(RuntimeError)
14
+
15
+ attr_accessor :token, :throttled_count
16
+
17
+ def initialize(mailbox)
18
+ super
19
+
20
+ reset
21
+ setup
22
+ end
23
+
24
+ def wait
25
+ process_mailbox
26
+
27
+ @throttled_count = 0
28
+ wait_for_new_messages
29
+ rescue TooManyRequestsError => e
30
+ @throttled_count += 1
31
+
32
+ @mailbox.logger.warn({ context: @mailbox.context, action: 'Too many requests, backing off...', backoff_s: backoff_secs, error: e.message, error_backtrace: e.backtrace })
33
+
34
+ backoff
35
+ rescue IOError => e
36
+ @mailbox.logger.warn({ context: @mailbox.context, action: 'Disconnected. Resetting...', error: e.message, error_backtrace: e.backtrace })
37
+
38
+ reset
39
+ setup
40
+ end
41
+
42
+ private
43
+
44
+ def wait_for_new_messages
45
+ sleep poll_interval
46
+ end
47
+
48
+ def backoff
49
+ sleep backoff_secs
50
+ end
51
+
52
+ def backoff_secs
53
+ [60 * 10, 2**throttled_count].min
54
+ end
55
+
56
+ def reset
57
+ @token = nil
58
+ @throttled_count = 0
59
+ end
60
+
61
+ def setup
62
+ @mailbox.logger.info({ context: @mailbox.context, action: 'Retrieving OAuth2 token...' })
63
+
64
+ @token = client.client_credentials.get_token({ scope: SCOPE })
65
+ end
66
+
67
+ def client
68
+ @client ||= OAuth2::Client.new(client_id, client_secret,
69
+ site: 'https://login.microsoftonline.com',
70
+ authorize_url: "/#{tenant_id}/oauth2/v2.0/authorize",
71
+ token_url: "/#{tenant_id}/oauth2/v2.0/token",
72
+ auth_scheme: :basic_auth)
73
+ end
74
+
75
+ def inbox_options
76
+ mailbox.inbox_options
77
+ end
78
+
79
+ def tenant_id
80
+ inbox_options[:tenant_id]
81
+ end
82
+
83
+ def client_id
84
+ inbox_options[:client_id]
85
+ end
86
+
87
+ def client_secret
88
+ inbox_options[:client_secret]
89
+ end
90
+
91
+ def poll_interval
92
+ @poll_interval ||= begin
93
+ interval = inbox_options[:poll_interval].to_i
94
+
95
+ if interval.positive?
96
+ interval
97
+ else
98
+ DEFAULT_POLL_INTERVAL_S
99
+ end
100
+ end
101
+ end
102
+
103
+ def process_mailbox
104
+ return unless @new_message_handler
105
+
106
+ @mailbox.logger.info({ context: @mailbox.context, action: 'Processing started' })
107
+
108
+ new_messages.each do |msg|
109
+ success = @new_message_handler.call(msg)
110
+ handle_delivered(msg) if success
111
+ end
112
+ end
113
+
114
+ def handle_delivered(msg)
115
+ mark_as_read(msg)
116
+ delete_message(msg) if @mailbox.delete_after_delivery
117
+ end
118
+
119
+ def delete_message(msg)
120
+ token.delete(msg_url(msg.uid))
121
+ end
122
+
123
+ def mark_as_read(msg)
124
+ token.patch(msg_url(msg.uid),
125
+ headers: { 'Content-Type' => 'application/json' },
126
+ body: { isRead: true }.to_json)
127
+ end
128
+
129
+ def new_messages
130
+ messages_for_ids(new_message_ids)
131
+ end
132
+
133
+ # Yields a page of message IDs at a time
134
+ def new_message_ids
135
+ url = unread_messages_url
136
+
137
+ Enumerator.new do |block|
138
+ loop do
139
+ messages, next_page_url = unread_messages(url: url)
140
+ messages.each { |msg| block.yield msg }
141
+
142
+ break unless next_page_url
143
+
144
+ url = next_page_url
145
+ end
146
+ end
147
+ end
148
+
149
+ def unread_messages(url:)
150
+ body = get(url)
151
+
152
+ return [[], nil] unless body
153
+
154
+ all_unread = body['value'].map { |msg| msg['id'] }
155
+ to_deliver = all_unread.select { |uid| @mailbox.deliver?(uid) }
156
+ @mailbox.logger.info({ context: @mailbox.context, action: 'Getting new messages',
157
+ unread: { count: all_unread.count, ids: all_unread },
158
+ to_be_delivered: { count: to_deliver.count, ids: to_deliver } })
159
+ [to_deliver, body[NEXT_PAGE_KEY]]
160
+ rescue TypeError, JSON::ParserError => e
161
+ log_exception('Error parsing JSON response', e)
162
+ [[], nil]
163
+ end
164
+
165
+ # Returns the JSON response
166
+ def get(url)
167
+ response = token.get(url, { raise_errors: false })
168
+
169
+ # https://docs.microsoft.com/en-us/graph/errors
170
+ case response.status
171
+ when 509, 429
172
+ raise TooManyRequestsError
173
+ when 400..599
174
+ raise OAuth2::Error, response
175
+ end
176
+
177
+ return unless response.body
178
+
179
+ body = JSON.parse(response.body)
180
+
181
+ raise TypeError, 'Response did not contain value hash' unless body.is_a?(Hash) && body.key?('value')
182
+
183
+ body
184
+ end
185
+
186
+ def messages_for_ids(message_ids)
187
+ message_ids.each_with_object([]) do |id, arr|
188
+ response = token.get(rfc822_msg_url(id))
189
+
190
+ arr << ::MailRoom::Message.new(uid: id, body: response.body)
191
+ end
192
+ end
193
+
194
+ def base_url
195
+ "https://graph.microsoft.com/v1.0/users/#{mailbox.email}/mailFolders/#{mailbox.name}/messages"
196
+ end
197
+
198
+ def unread_messages_url
199
+ "#{base_url}?$filter=isRead eq false"
200
+ end
201
+
202
+ def msg_url(id)
203
+ # Attempting to use the base_url fails with "The OData request is not supported"
204
+ "https://graph.microsoft.com/v1.0/users/#{mailbox.email}/messages/#{id}"
205
+ end
206
+
207
+ def rfc822_msg_url(id)
208
+ # Attempting to use the base_url fails with "The OData request is not supported"
209
+ "#{msg_url(id)}/$value"
210
+ end
211
+
212
+ def log_exception(message, exception)
213
+ @mailbox.logger.warn({ context: @mailbox.context, message: message, exception: exception.to_s })
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailRoom
4
+ module MicrosoftGraph
5
+ autoload :Connection, 'mail_room/microsoft_graph/connection'
6
+ end
7
+ end
@@ -1,4 +1,4 @@
1
1
  module MailRoom
2
2
  # Current version of gitlab-mail_room gem
3
- VERSION = "0.0.10"
3
+ VERSION = "0.0.14"
4
4
  end
data/mail_room.gemspec CHANGED
@@ -18,9 +18,14 @@ Gem::Specification.new do |gem|
18
18
  gem.require_paths = ["lib"]
19
19
 
20
20
  gem.add_dependency "net-imap", ">= 0.2.1"
21
+ gem.add_dependency "oauth2", "~> 1.4.4"
22
+
23
+ # Pinning io-wait to 0.1.0, which is the last version to support Ruby < 3
24
+ gem.add_dependency "io-wait", "~> 0.1.0"
21
25
 
22
26
  gem.add_development_dependency "rake"
23
27
  gem.add_development_dependency "rspec", "~> 3.9"
28
+ gem.add_development_dependency "rubocop", "~> 1.11"
24
29
  gem.add_development_dependency "mocha", "~> 1.11"
25
30
  gem.add_development_dependency "simplecov"
26
31
  gem.add_development_dependency "webrick", "~> 1.6"
@@ -33,4 +38,5 @@ Gem::Specification.new do |gem|
33
38
  gem.add_development_dependency "redis-namespace"
34
39
  gem.add_development_dependency "pg"
35
40
  gem.add_development_dependency "charlock_holmes"
41
+ gem.add_development_dependency "webmock"
36
42
  end
data/spec/lib/cli_spec.rb CHANGED
@@ -2,8 +2,8 @@ require 'spec_helper'
2
2
 
3
3
  describe MailRoom::CLI do
4
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)}
5
+ let!(:configuration) {MailRoom::Configuration.new({config_path: config_path})}
6
+ let(:coordinator) {stub(run: true, quit: true)}
7
7
  let(:configuration_args) { anything }
8
8
  let(:coordinator_args) { [anything, anything] }
9
9
 
@@ -17,7 +17,7 @@ describe MailRoom::CLI do
17
17
 
18
18
  context 'with configuration args' do
19
19
  let(:configuration_args) do
20
- {:config_path => 'a path'}
20
+ {config_path: 'a path'}
21
21
  end
22
22
 
23
23
  it 'parses arguments into configuration' do
@@ -5,7 +5,7 @@ describe MailRoom::Configuration do
5
5
 
6
6
  describe '#initalize' do
7
7
  context 'with config_path' do
8
- let(:configuration) { MailRoom::Configuration.new(:config_path => config_path) }
8
+ let(:configuration) { MailRoom::Configuration.new(config_path: config_path) }
9
9
 
10
10
  it 'parses yaml into mailbox objects' do
11
11
  MailRoom::Mailbox.stubs(:new).returns('mailbox1', 'mailbox2')
@@ -3,7 +3,7 @@ require 'mail_room/delivery/letter_opener'
3
3
 
4
4
  describe MailRoom::Delivery::LetterOpener do
5
5
  describe '#deliver' do
6
- let(:mailbox) {build_mailbox(:location => '/tmp/somewhere')}
6
+ let(:mailbox) {build_mailbox(location: '/tmp/somewhere')}
7
7
  let(:delivery_method) {stub(:deliver!)}
8
8
  let(:mail) {stub}
9
9
 
@@ -13,7 +13,7 @@ describe MailRoom::Delivery::LetterOpener do
13
13
  end
14
14
 
15
15
  it 'creates a new LetterOpener::DeliveryMethod' do
16
- ::LetterOpener::DeliveryMethod.expects(:new).with(:location => '/tmp/somewhere').returns(delivery_method)
16
+ ::LetterOpener::DeliveryMethod.expects(:new).with(location: '/tmp/somewhere').returns(delivery_method)
17
17
 
18
18
  MailRoom::Delivery::LetterOpener.new(mailbox).deliver('a message')
19
19
  end
@@ -16,7 +16,7 @@ describe MailRoom::Delivery::Logger do
16
16
  end
17
17
 
18
18
  context "with a log path" do
19
- let(:mailbox) {build_mailbox(:log_path => '/var/log/mail-room.log')}
19
+ let(:mailbox) {build_mailbox(log_path: '/var/log/mail-room.log')}
20
20
 
21
21
  it 'creates a new file to append to' do
22
22
  file = stub(:sync=)
@@ -5,8 +5,8 @@ describe MailRoom::Delivery::Postback do
5
5
  describe '#deliver' do
6
6
  context 'with token auth delivery' do
7
7
  let(:mailbox) {build_mailbox({
8
- :delivery_url => 'http://localhost/inbox',
9
- :delivery_token => 'abcdefg'
8
+ delivery_url: 'http://localhost/inbox',
9
+ delivery_token: 'abcdefg'
10
10
  })}
11
11
 
12
12
  let(:delivery_options) {
@@ -30,10 +30,10 @@ describe MailRoom::Delivery::Postback do
30
30
 
31
31
  context 'with basic auth delivery options' do
32
32
  let(:mailbox) {build_mailbox({
33
- :delivery_options => {
34
- :url => 'http://localhost/inbox',
35
- :username => 'user1',
36
- :password => 'password123abc'
33
+ delivery_options: {
34
+ url: 'http://localhost/inbox',
35
+ username: 'user1',
36
+ password: 'password123abc'
37
37
  }
38
38
  })}
39
39
 
@@ -57,11 +57,11 @@ describe MailRoom::Delivery::Postback do
57
57
 
58
58
  context 'with content type in the delivery options' do
59
59
  let(:mailbox) {build_mailbox({
60
- :delivery_options => {
61
- :url => 'http://localhost/inbox',
62
- :username => 'user1',
63
- :password => 'password123abc',
64
- :content_type => 'text/plain'
60
+ delivery_options: {
61
+ url: 'http://localhost/inbox',
62
+ username: 'user1',
63
+ password: 'password123abc',
64
+ content_type: 'text/plain'
65
65
  }
66
66
  })}
67
67
 
@@ -8,23 +8,45 @@ describe MailRoom::Delivery::Sidekiq do
8
8
 
9
9
  describe '#options' do
10
10
  let(:redis_url) { 'redis://localhost' }
11
+ let(:redis_options) { { redis_url: redis_url } }
11
12
 
12
13
  context 'when only redis_url is specified' do
13
14
  let(:mailbox) {
14
15
  build_mailbox(
15
16
  delivery_method: :sidekiq,
16
- delivery_options: {
17
- redis_url: redis_url
18
- }
17
+ delivery_options: redis_options
19
18
  )
20
19
  }
21
20
 
22
- it 'client has same specified redis_url' do
23
- expect(redis.client.options[:url]).to eq(redis_url)
21
+ context 'with simple redis url' do
22
+ it 'client has same specified redis_url' do
23
+ expect(redis.client.options[:url]).to eq(redis_url)
24
+ end
25
+
26
+ it 'client is a instance of RedisNamespace class' do
27
+ expect(redis).to be_a ::Redis
28
+ end
29
+
30
+ it 'connection has correct values' do
31
+ expect(redis.connection[:host]).to eq('localhost')
32
+ expect(redis.connection[:db]).to eq(0)
33
+ end
24
34
  end
25
35
 
26
- it 'client is a instance of RedisNamespace class' do
27
- expect(redis).to be_a ::Redis
36
+ context 'with redis_db specified in options' do
37
+ before do
38
+ redis_options[:redis_db] = 4
39
+ end
40
+
41
+ it 'client has correct redis_url' do
42
+ expect(redis.client.options[:url]).to eq(redis_url)
43
+ end
44
+
45
+
46
+ it 'connection has correct values' do
47
+ expect(redis.connection[:host]).to eq('localhost')
48
+ expect(redis.connection[:db]).to eq(4)
49
+ end
28
50
  end
29
51
  end
30
52