gitlab-mail_room 0.0.10 → 0.0.19

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.
@@ -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