gitlab-mail_room 0.0.6 → 0.0.11
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.
- checksums.yaml +4 -4
- data/.gitlab-ci.yml +9 -5
- data/.ruby-version +1 -1
- data/.travis.yml +3 -2
- data/README.md +104 -10
- data/lib/mail_room.rb +2 -0
- data/lib/mail_room/cli.rb +1 -1
- data/lib/mail_room/configuration.rb +11 -1
- data/lib/mail_room/connection.rb +7 -179
- data/lib/mail_room/coordinator.rb +8 -4
- data/lib/mail_room/crash_handler.rb +2 -2
- data/lib/mail_room/health_check.rb +60 -0
- data/lib/mail_room/imap.rb +8 -0
- data/lib/mail_room/imap/connection.rb +200 -0
- data/lib/mail_room/imap/message.rb +19 -0
- data/lib/mail_room/logger/structured.rb +15 -1
- data/lib/mail_room/mailbox.rb +49 -7
- data/lib/mail_room/mailbox_watcher.rb +15 -2
- data/lib/mail_room/message.rb +16 -0
- data/lib/mail_room/microsoft_graph.rb +7 -0
- data/lib/mail_room/microsoft_graph/connection.rb +216 -0
- data/lib/mail_room/version.rb +1 -1
- data/mail_room.gemspec +5 -0
- data/spec/fixtures/test_config.yml +3 -0
- data/spec/lib/cli_spec.rb +3 -3
- data/spec/lib/configuration_spec.rb +9 -1
- data/spec/lib/coordinator_spec.rb +16 -2
- data/spec/lib/health_check_spec.rb +57 -0
- data/spec/lib/{connection_spec.rb → imap/connection_spec.rb} +12 -8
- data/spec/lib/imap/message_spec.rb +36 -0
- data/spec/lib/logger/structured_spec.rb +34 -2
- data/spec/lib/mailbox_spec.rb +58 -10
- data/spec/lib/mailbox_watcher_spec.rb +54 -38
- data/spec/lib/message_spec.rb +35 -0
- data/spec/lib/microsoft_graph/connection_spec.rb +183 -0
- data/spec/spec_helper.rb +11 -0
- metadata +76 -5
@@ -1,3 +1,5 @@
|
|
1
|
+
require "mail_room/connection"
|
2
|
+
|
1
3
|
module MailRoom
|
2
4
|
# TODO: split up between processing and idling?
|
3
5
|
|
@@ -49,14 +51,25 @@ module MailRoom
|
|
49
51
|
@connection = nil
|
50
52
|
end
|
51
53
|
|
54
|
+
@mailbox.logger.info({ context: @mailbox.context, action: "Terminating watching thread..." })
|
55
|
+
|
52
56
|
if self.watching_thread
|
53
|
-
self.watching_thread.join
|
57
|
+
thr = self.watching_thread.join(60)
|
58
|
+
@mailbox.logger.info({ context: @mailbox.context, action: "Timeout waiting for watching thread" }) unless thr
|
54
59
|
end
|
60
|
+
|
61
|
+
@mailbox.logger.info({ context: @mailbox.context, action: "Done with thread cleanup" })
|
55
62
|
end
|
56
63
|
|
57
64
|
private
|
65
|
+
|
58
66
|
def connection
|
59
|
-
@connection ||=
|
67
|
+
@connection ||=
|
68
|
+
if @mailbox.microsoft_graph?
|
69
|
+
::MailRoom::MicrosoftGraph::Connection.new(@mailbox)
|
70
|
+
else
|
71
|
+
::MailRoom::IMAP::Connection.new(@mailbox)
|
72
|
+
end
|
60
73
|
end
|
61
74
|
end
|
62
75
|
end
|
@@ -0,0 +1,16 @@
|
|
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
|
@@ -0,0 +1,216 @@
|
|
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 OAuth2::Error, 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
|
+
case response.status
|
170
|
+
when 429
|
171
|
+
raise TooManyRequestsError
|
172
|
+
when 400..599
|
173
|
+
raise OAuth2::Error, response
|
174
|
+
end
|
175
|
+
|
176
|
+
return unless response.body
|
177
|
+
|
178
|
+
body = JSON.parse(response.body)
|
179
|
+
|
180
|
+
raise TypeError, 'Response did not contain value hash' unless body.is_a?(Hash) && body.key?('value')
|
181
|
+
|
182
|
+
body
|
183
|
+
end
|
184
|
+
|
185
|
+
def messages_for_ids(message_ids)
|
186
|
+
message_ids.each_with_object([]) do |id, arr|
|
187
|
+
response = token.get(rfc822_msg_url(id))
|
188
|
+
|
189
|
+
arr << ::MailRoom::Message.new(uid: id, body: response.body)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def base_url
|
194
|
+
"https://graph.microsoft.com/v1.0/users/#{mailbox.email}/mailFolders/#{mailbox.name}/messages"
|
195
|
+
end
|
196
|
+
|
197
|
+
def unread_messages_url
|
198
|
+
"#{base_url}?$filter=isRead eq false"
|
199
|
+
end
|
200
|
+
|
201
|
+
def msg_url(id)
|
202
|
+
# Attempting to use the base_url fails with "The OData request is not supported"
|
203
|
+
"https://graph.microsoft.com/v1.0/users/#{mailbox.email}/messages/#{id}"
|
204
|
+
end
|
205
|
+
|
206
|
+
def rfc822_msg_url(id)
|
207
|
+
# Attempting to use the base_url fails with "The OData request is not supported"
|
208
|
+
"#{msg_url(id)}/$value"
|
209
|
+
end
|
210
|
+
|
211
|
+
def log_exception(message, exception)
|
212
|
+
@mailbox.logger.warn({ context: @mailbox.context, message: message, exception: exception.to_s })
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
data/lib/mail_room/version.rb
CHANGED
data/mail_room.gemspec
CHANGED
@@ -17,10 +17,14 @@ Gem::Specification.new do |gem|
|
|
17
17
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
18
18
|
gem.require_paths = ["lib"]
|
19
19
|
|
20
|
+
gem.add_dependency "net-imap", ">= 0.2.1"
|
21
|
+
gem.add_dependency "oauth2", "~> 1.4.4"
|
22
|
+
|
20
23
|
gem.add_development_dependency "rake"
|
21
24
|
gem.add_development_dependency "rspec", "~> 3.9"
|
22
25
|
gem.add_development_dependency "mocha", "~> 1.11"
|
23
26
|
gem.add_development_dependency "simplecov"
|
27
|
+
gem.add_development_dependency "webrick", "~> 1.6"
|
24
28
|
|
25
29
|
# for testing delivery methods
|
26
30
|
gem.add_development_dependency "faraday"
|
@@ -30,4 +34,5 @@ Gem::Specification.new do |gem|
|
|
30
34
|
gem.add_development_dependency "redis-namespace"
|
31
35
|
gem.add_development_dependency "pg"
|
32
36
|
gem.add_development_dependency "charlock_holmes"
|
37
|
+
gem.add_development_dependency "webmock"
|
33
38
|
end
|
data/spec/lib/cli_spec.rb
CHANGED
@@ -5,14 +5,14 @@ describe MailRoom::CLI do
|
|
5
5
|
let!(:configuration) {MailRoom::Configuration.new({:config_path => config_path})}
|
6
6
|
let(:coordinator) {stub(:run => true, :quit => true)}
|
7
7
|
let(:configuration_args) { anything }
|
8
|
-
let(:coordinator_args) { anything }
|
8
|
+
let(:coordinator_args) { [anything, anything] }
|
9
9
|
|
10
10
|
describe '.new' do
|
11
11
|
let(:args) {["-c", "a path"]}
|
12
12
|
|
13
13
|
before :each do
|
14
14
|
MailRoom::Configuration.expects(:new).with(configuration_args).returns(configuration)
|
15
|
-
MailRoom::Coordinator.stubs(:new).with(coordinator_args).returns(coordinator)
|
15
|
+
MailRoom::Coordinator.stubs(:new).with(*coordinator_args).returns(coordinator)
|
16
16
|
end
|
17
17
|
|
18
18
|
context 'with configuration args' do
|
@@ -27,7 +27,7 @@ describe MailRoom::CLI do
|
|
27
27
|
|
28
28
|
context 'with coordinator args' do
|
29
29
|
let(:coordinator_args) do
|
30
|
-
configuration.mailboxes
|
30
|
+
[configuration.mailboxes, anything]
|
31
31
|
end
|
32
32
|
|
33
33
|
it 'creates a new coordinator with configuration' do
|
@@ -3,7 +3,7 @@ require 'spec_helper'
|
|
3
3
|
describe MailRoom::Configuration do
|
4
4
|
let(:config_path) {File.expand_path('../fixtures/test_config.yml', File.dirname(__FILE__))}
|
5
5
|
|
6
|
-
describe '
|
6
|
+
describe '#initalize' do
|
7
7
|
context 'with config_path' do
|
8
8
|
let(:configuration) { MailRoom::Configuration.new(:config_path => config_path) }
|
9
9
|
|
@@ -12,6 +12,10 @@ describe MailRoom::Configuration do
|
|
12
12
|
|
13
13
|
expect(configuration.mailboxes).to eq(['mailbox1', 'mailbox2'])
|
14
14
|
end
|
15
|
+
|
16
|
+
it 'parses health check' do
|
17
|
+
expect(configuration.health_check).to be_a(MailRoom::HealthCheck)
|
18
|
+
end
|
15
19
|
end
|
16
20
|
|
17
21
|
context 'without config_path' do
|
@@ -23,6 +27,10 @@ describe MailRoom::Configuration do
|
|
23
27
|
|
24
28
|
expect(configuration.mailboxes).to eq([])
|
25
29
|
end
|
30
|
+
|
31
|
+
it 'sets the health check to nil' do
|
32
|
+
expect(configuration.health_check).to be_nil
|
33
|
+
end
|
26
34
|
end
|
27
35
|
end
|
28
36
|
end
|
@@ -15,6 +15,13 @@ describe MailRoom::Coordinator do
|
|
15
15
|
coordinator = MailRoom::Coordinator.new([])
|
16
16
|
expect(coordinator.watchers).to eq([])
|
17
17
|
end
|
18
|
+
|
19
|
+
it 'sets the health check' do
|
20
|
+
health_check = MailRoom::HealthCheck.new({ address: '127.0.0.1', port: 8080})
|
21
|
+
coordinator = MailRoom::Coordinator.new([], health_check)
|
22
|
+
|
23
|
+
expect(coordinator.health_check).to eq(health_check)
|
24
|
+
end
|
18
25
|
end
|
19
26
|
|
20
27
|
describe '#run' do
|
@@ -22,15 +29,22 @@ describe MailRoom::Coordinator do
|
|
22
29
|
watcher = stub
|
23
30
|
watcher.stubs(:run)
|
24
31
|
watcher.stubs(:quit)
|
32
|
+
|
33
|
+
health_check = stub
|
34
|
+
health_check.stubs(:run)
|
35
|
+
health_check.stubs(:quit)
|
36
|
+
|
25
37
|
MailRoom::MailboxWatcher.stubs(:new).returns(watcher)
|
26
|
-
coordinator = MailRoom::Coordinator.new(['mailbox1'])
|
38
|
+
coordinator = MailRoom::Coordinator.new(['mailbox1'], health_check)
|
27
39
|
coordinator.stubs(:sleep_while_running)
|
28
40
|
watcher.expects(:run)
|
29
41
|
watcher.expects(:quit)
|
42
|
+
health_check.expects(:run)
|
43
|
+
health_check.expects(:quit)
|
30
44
|
|
31
45
|
coordinator.run
|
32
46
|
end
|
33
|
-
|
47
|
+
|
34
48
|
it 'should go to sleep after running watchers' do
|
35
49
|
coordinator = MailRoom::Coordinator.new([])
|
36
50
|
coordinator.stubs(:running=)
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe MailRoom::HealthCheck do
|
6
|
+
let(:address) { '127.0.0.1' }
|
7
|
+
let(:port) { 8000 }
|
8
|
+
let(:params) { { address: address, port: port } }
|
9
|
+
subject { described_class.new(params) }
|
10
|
+
|
11
|
+
describe '#initialize' do
|
12
|
+
context 'with valid parameters' do
|
13
|
+
it 'validates successfully' do
|
14
|
+
expect(subject).to be_a(described_class)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context 'with invalid address' do
|
19
|
+
let(:address) { nil }
|
20
|
+
|
21
|
+
it 'raises an error' do
|
22
|
+
expect { subject }.to raise_error('No health check address specified')
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
context 'with invalid port' do
|
27
|
+
let(:port) { nil }
|
28
|
+
|
29
|
+
it 'raises an error' do
|
30
|
+
expect { subject }.to raise_error('Health check port 0 is invalid')
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe '#run' do
|
36
|
+
it 'sets running to true' do
|
37
|
+
server = stub(start: true)
|
38
|
+
subject.stubs(:create_server).returns(server)
|
39
|
+
|
40
|
+
subject.run
|
41
|
+
|
42
|
+
expect(subject.running).to be true
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe '#quit' do
|
47
|
+
it 'sets running to false' do
|
48
|
+
server = stub(start: true, shutdown: true)
|
49
|
+
subject.stubs(:create_server).returns(server)
|
50
|
+
|
51
|
+
subject.run
|
52
|
+
subject.quit
|
53
|
+
|
54
|
+
expect(subject.running).to be false
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|