gitlab-mail_room 0.0.10 → 0.0.19

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,232 @@
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
+ return if stopped?
26
+
27
+ process_mailbox
28
+
29
+ @throttled_count = 0
30
+ wait_for_new_messages
31
+ rescue TooManyRequestsError => e
32
+ @throttled_count += 1
33
+
34
+ @mailbox.logger.warn({ context: @mailbox.context, action: 'Too many requests, backing off...', backoff_s: backoff_secs, error: e.message, error_backtrace: e.backtrace })
35
+
36
+ backoff
37
+ rescue IOError => e
38
+ @mailbox.logger.warn({ context: @mailbox.context, action: 'Disconnected. Resetting...', error: e.message, error_backtrace: e.backtrace })
39
+
40
+ reset
41
+ setup
42
+ end
43
+
44
+ private
45
+
46
+ def wait_for_new_messages
47
+ sleep_while_running(poll_interval)
48
+ end
49
+
50
+ def backoff
51
+ sleep_while_running(backoff_secs)
52
+ end
53
+
54
+ def backoff_secs
55
+ [60 * 10, 2**throttled_count].min
56
+ end
57
+
58
+ # Unless wake up periodically, we won't notice that the thread was stopped
59
+ # if we sleep the entire interval.
60
+ def sleep_while_running(sleep_interval)
61
+ sleep_interval.times do
62
+ do_sleep(1)
63
+ return if stopped?
64
+ end
65
+ end
66
+
67
+ def do_sleep(interval)
68
+ sleep(interval)
69
+ end
70
+
71
+ def reset
72
+ @token = nil
73
+ @throttled_count = 0
74
+ end
75
+
76
+ def setup
77
+ @mailbox.logger.info({ context: @mailbox.context, action: 'Retrieving OAuth2 token...' })
78
+
79
+ @token = client.client_credentials.get_token({ scope: SCOPE })
80
+ end
81
+
82
+ def client
83
+ @client ||= OAuth2::Client.new(client_id, client_secret,
84
+ site: 'https://login.microsoftonline.com',
85
+ authorize_url: "/#{tenant_id}/oauth2/v2.0/authorize",
86
+ token_url: "/#{tenant_id}/oauth2/v2.0/token",
87
+ auth_scheme: :basic_auth)
88
+ end
89
+
90
+ def inbox_options
91
+ mailbox.inbox_options
92
+ end
93
+
94
+ def tenant_id
95
+ inbox_options[:tenant_id]
96
+ end
97
+
98
+ def client_id
99
+ inbox_options[:client_id]
100
+ end
101
+
102
+ def client_secret
103
+ inbox_options[:client_secret]
104
+ end
105
+
106
+ def poll_interval
107
+ @poll_interval ||= begin
108
+ interval = inbox_options[:poll_interval].to_i
109
+
110
+ if interval.positive?
111
+ interval
112
+ else
113
+ DEFAULT_POLL_INTERVAL_S
114
+ end
115
+ end
116
+ end
117
+
118
+ def process_mailbox
119
+ return unless @new_message_handler
120
+
121
+ @mailbox.logger.info({ context: @mailbox.context, action: 'Processing started' })
122
+
123
+ new_messages.each do |msg|
124
+ success = @new_message_handler.call(msg)
125
+ handle_delivered(msg) if success
126
+ end
127
+ end
128
+
129
+ def handle_delivered(msg)
130
+ mark_as_read(msg)
131
+ delete_message(msg) if @mailbox.delete_after_delivery
132
+ end
133
+
134
+ def delete_message(msg)
135
+ token.delete(msg_url(msg.uid))
136
+ end
137
+
138
+ def mark_as_read(msg)
139
+ token.patch(msg_url(msg.uid),
140
+ headers: { 'Content-Type' => 'application/json' },
141
+ body: { isRead: true }.to_json)
142
+ end
143
+
144
+ def new_messages
145
+ messages_for_ids(new_message_ids)
146
+ end
147
+
148
+ # Yields a page of message IDs at a time
149
+ def new_message_ids
150
+ url = unread_messages_url
151
+
152
+ Enumerator.new do |block|
153
+ loop do
154
+ messages, next_page_url = unread_messages(url: url)
155
+ messages.each { |msg| block.yield msg }
156
+
157
+ break unless next_page_url
158
+
159
+ url = next_page_url
160
+ end
161
+ end
162
+ end
163
+
164
+ def unread_messages(url:)
165
+ body = get(url)
166
+
167
+ return [[], nil] unless body
168
+
169
+ all_unread = body['value'].map { |msg| msg['id'] }
170
+ to_deliver = all_unread.select { |uid| @mailbox.deliver?(uid) }
171
+ @mailbox.logger.info({ context: @mailbox.context, action: 'Getting new messages',
172
+ unread: { count: all_unread.count, ids: all_unread },
173
+ to_be_delivered: { count: to_deliver.count, ids: to_deliver } })
174
+ [to_deliver, body[NEXT_PAGE_KEY]]
175
+ rescue TypeError, JSON::ParserError => e
176
+ log_exception('Error parsing JSON response', e)
177
+ [[], nil]
178
+ end
179
+
180
+ # Returns the JSON response
181
+ def get(url)
182
+ response = token.get(url, { raise_errors: false })
183
+
184
+ # https://docs.microsoft.com/en-us/graph/errors
185
+ case response.status
186
+ when 509, 429
187
+ raise TooManyRequestsError
188
+ when 400..599
189
+ raise OAuth2::Error, response
190
+ end
191
+
192
+ return unless response.body
193
+
194
+ body = JSON.parse(response.body)
195
+
196
+ raise TypeError, 'Response did not contain value hash' unless body.is_a?(Hash) && body.key?('value')
197
+
198
+ body
199
+ end
200
+
201
+ def messages_for_ids(message_ids)
202
+ message_ids.each_with_object([]) do |id, arr|
203
+ response = token.get(rfc822_msg_url(id))
204
+
205
+ arr << ::MailRoom::Message.new(uid: id, body: response.body)
206
+ end
207
+ end
208
+
209
+ def base_url
210
+ "https://graph.microsoft.com/v1.0/users/#{mailbox.email}/mailFolders/#{mailbox.name}/messages"
211
+ end
212
+
213
+ def unread_messages_url
214
+ "#{base_url}?$filter=isRead eq false"
215
+ end
216
+
217
+ def msg_url(id)
218
+ # Attempting to use the base_url fails with "The OData request is not supported"
219
+ "https://graph.microsoft.com/v1.0/users/#{mailbox.email}/messages/#{id}"
220
+ end
221
+
222
+ def rfc822_msg_url(id)
223
+ # Attempting to use the base_url fails with "The OData request is not supported"
224
+ "#{msg_url(id)}/$value"
225
+ end
226
+
227
+ def log_exception(message, exception)
228
+ @mailbox.logger.warn({ context: @mailbox.context, message: message, exception: exception.to_s })
229
+ end
230
+ end
231
+ end
232
+ 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.19"
4
4
  end
data/mail_room.gemspec CHANGED
@@ -18,9 +18,15 @@ 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
+ gem.add_dependency "jwt", ">= 2.0"
23
+
24
+ # Pinning io-wait to 0.1.0, which is the last version to support Ruby < 3
25
+ gem.add_dependency "io-wait", "~> 0.1.0"
21
26
 
22
27
  gem.add_development_dependency "rake"
23
28
  gem.add_development_dependency "rspec", "~> 3.9"
29
+ gem.add_development_dependency "rubocop", "~> 1.11"
24
30
  gem.add_development_dependency "mocha", "~> 1.11"
25
31
  gem.add_development_dependency "simplecov"
26
32
  gem.add_development_dependency "webrick", "~> 1.6"
@@ -29,8 +35,9 @@ Gem::Specification.new do |gem|
29
35
  gem.add_development_dependency "faraday"
30
36
  gem.add_development_dependency "mail"
31
37
  gem.add_development_dependency "letter_opener"
32
- gem.add_development_dependency "redis", "~> 3.3.1"
38
+ gem.add_development_dependency "redis", "~> 4"
33
39
  gem.add_development_dependency "redis-namespace"
34
40
  gem.add_development_dependency "pg"
35
41
  gem.add_development_dependency "charlock_holmes"
42
+ gem.add_development_dependency "webmock"
36
43
  end
@@ -0,0 +1 @@
1
+ aGVsbG93b3JsZA==
@@ -15,6 +15,7 @@ describe MailRoom::Arbitration::Redis do
15
15
 
16
16
  # Private, but we don't care.
17
17
  let(:redis) { subject.send(:client) }
18
+ let(:raw_client) { redis._client }
18
19
 
19
20
  describe '#deliver?' do
20
21
  context "when called the first time" do
@@ -95,7 +96,7 @@ describe MailRoom::Arbitration::Redis do
95
96
  it 'client has same specified url' do
96
97
  subject.deliver?(123)
97
98
 
98
- expect(redis.client.options[:url]).to eq redis_url
99
+ expect(raw_client.options[:url]).to eq redis_url
99
100
  end
100
101
 
101
102
  it 'client is a instance of Redis class' do
@@ -137,10 +138,10 @@ describe MailRoom::Arbitration::Redis do
137
138
  before { ::Redis::Client::Connector::Sentinel.any_instance.stubs(:resolve).returns(sentinels) }
138
139
 
139
140
  it 'client has same specified sentinel params' do
140
- expect(redis.client.instance_variable_get(:@connector)).to be_a Redis::Client::Connector::Sentinel
141
- expect(redis.client.options[:host]).to eq('sentinel-master')
142
- expect(redis.client.options[:password]).to eq('mypassword')
143
- expect(redis.client.options[:sentinels]).to eq(sentinels)
141
+ expect(raw_client.instance_variable_get(:@connector)).to be_a Redis::Client::Connector::Sentinel
142
+ expect(raw_client.options[:host]).to eq('sentinel-master')
143
+ expect(raw_client.options[:password]).to eq('mypassword')
144
+ expect(raw_client.options[:sentinels]).to eq(sentinels)
144
145
  end
145
146
  end
146
147
  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,19 +57,18 @@ 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
 
68
-
69
68
  let(:delivery_options) {
70
69
  MailRoom::Delivery::Postback::Options.new(mailbox)
71
70
  }
72
-
71
+
73
72
  it 'posts the message with faraday' do
74
73
  connection = stub
75
74
  request = stub
@@ -82,10 +81,59 @@ describe MailRoom::Delivery::Postback do
82
81
  connection.expects(:basic_auth).with('user1', 'password123abc')
83
82
 
84
83
  MailRoom::Delivery::Postback.new(delivery_options).deliver('a message')
85
-
84
+
86
85
  expect(request.headers['Content-Type']).to eq('text/plain')
87
86
  end
88
87
  end
88
+
89
+ context 'with jwt token in the delivery options' do
90
+ let(:mailbox) {build_mailbox({
91
+ delivery_options: {
92
+ url: 'http://localhost/inbox',
93
+ jwt_auth_header: "Mailroom-Api-Request",
94
+ jwt_issuer: "mailroom",
95
+ jwt_algorithm: "HS256",
96
+ jwt_secret_path: "secret_path"
97
+ }
98
+ })}
99
+
100
+ let(:delivery_options) {
101
+ MailRoom::Delivery::Postback::Options.new(mailbox)
102
+ }
103
+
104
+ it 'posts the message with faraday' do
105
+ connection = stub
106
+ request = stub
107
+ Faraday.stubs(:new).returns(connection)
108
+
109
+ connection.expects(:post).yields(request).twice
110
+ request.stubs(:url)
111
+ request.stubs(:body=)
112
+ request.stubs(:headers).returns({})
113
+
114
+ jwt = stub
115
+ MailRoom::JWT.expects(:new).with(
116
+ header: 'Mailroom-Api-Request',
117
+ issuer: 'mailroom',
118
+ algorithm: 'HS256',
119
+ secret_path: 'secret_path'
120
+ ).returns(jwt)
121
+ jwt.stubs(:valid?).returns(true)
122
+ jwt.stubs(:header).returns('Mailroom-Api-Request')
123
+ jwt.stubs(:token).returns('a_jwt_token')
124
+
125
+ delivery = MailRoom::Delivery::Postback.new(delivery_options)
126
+
127
+ delivery.deliver('a message')
128
+ expect(request.headers['Mailroom-Api-Request']).to eql('a_jwt_token')
129
+
130
+ # A different jwt token for the second time
131
+ jwt.stubs(:token).returns('another_jwt_token')
132
+
133
+ delivery.deliver('another message')
134
+ expect(request.headers['Mailroom-Api-Request']).to eql('another_jwt_token')
135
+ end
136
+ end
89
137
  end
90
138
  end
91
139
  end
@@ -4,27 +4,50 @@ require 'mail_room/delivery/sidekiq'
4
4
  describe MailRoom::Delivery::Sidekiq do
5
5
  subject { described_class.new(options) }
6
6
  let(:redis) { subject.send(:client) }
7
+ let(:raw_client) { redis._client }
7
8
  let(:options) { MailRoom::Delivery::Sidekiq::Options.new(mailbox) }
8
9
 
9
10
  describe '#options' do
10
11
  let(:redis_url) { 'redis://localhost' }
12
+ let(:redis_options) { { redis_url: redis_url } }
11
13
 
12
14
  context 'when only redis_url is specified' do
13
15
  let(:mailbox) {
14
16
  build_mailbox(
15
17
  delivery_method: :sidekiq,
16
- delivery_options: {
17
- redis_url: redis_url
18
- }
18
+ delivery_options: redis_options
19
19
  )
20
20
  }
21
21
 
22
- it 'client has same specified redis_url' do
23
- expect(redis.client.options[:url]).to eq(redis_url)
22
+ context 'with simple redis url' do
23
+ it 'client has same specified redis_url' do
24
+ expect(raw_client.options[: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
24
35
  end
25
36
 
26
- it 'client is a instance of RedisNamespace class' do
27
- expect(redis).to be_a ::Redis
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.options[:url]).to eq(redis_url)
44
+ end
45
+
46
+
47
+ it 'connection has correct values' do
48
+ expect(redis.connection[:host]).to eq('localhost')
49
+ expect(redis.connection[:db]).to eq(4)
50
+ end
28
51
  end
29
52
  end
30
53
 
@@ -65,10 +88,10 @@ describe MailRoom::Delivery::Sidekiq do
65
88
  before { ::Redis::Client::Connector::Sentinel.any_instance.stubs(:resolve).returns(sentinels) }
66
89
 
67
90
  it 'client has same specified sentinel params' do
68
- expect(redis.client.instance_variable_get(:@connector)).to be_a Redis::Client::Connector::Sentinel
69
- expect(redis.client.options[:host]).to eq('sentinel-master')
70
- expect(redis.client.options[:password]).to eq('mypassword')
71
- expect(redis.client.options[:sentinels]).to eq(sentinels)
91
+ expect(raw_client.instance_variable_get(:@connector)).to be_a Redis::Client::Connector::Sentinel
92
+ expect(raw_client.options[:host]).to eq('sentinel-master')
93
+ expect(raw_client.options[:password]).to eq('mypassword')
94
+ expect(raw_client.options[:sentinels]).to eq(sentinels)
72
95
  end
73
96
  end
74
97
 
@@ -0,0 +1,80 @@
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