mail_room 0.10.0 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +10 -0
  3. data/.github/workflows/ci.yml +54 -0
  4. data/.gitlab-ci.yml +14 -12
  5. data/.rubocop.yml +6 -0
  6. data/.rubocop_todo.yml +497 -0
  7. data/.ruby-version +1 -1
  8. data/.tool-versions +1 -0
  9. data/CHANGELOG.md +8 -0
  10. data/README.md +136 -28
  11. data/Rakefile +1 -1
  12. data/lib/mail_room/arbitration/redis.rb +8 -1
  13. data/lib/mail_room/cli.rb +9 -2
  14. data/lib/mail_room/connection.rb +10 -177
  15. data/lib/mail_room/crash_handler.rb +26 -0
  16. data/lib/mail_room/delivery/letter_opener.rb +1 -1
  17. data/lib/mail_room/delivery/postback.rb +60 -10
  18. data/lib/mail_room/delivery/que.rb +15 -1
  19. data/lib/mail_room/delivery/sidekiq.rb +11 -3
  20. data/lib/mail_room/imap/connection.rb +200 -0
  21. data/lib/mail_room/imap/message.rb +19 -0
  22. data/lib/mail_room/imap.rb +8 -0
  23. data/lib/mail_room/jwt.rb +39 -0
  24. data/lib/mail_room/logger/structured.rb +15 -1
  25. data/lib/mail_room/mailbox.rb +77 -21
  26. data/lib/mail_room/mailbox_watcher.rb +9 -1
  27. data/lib/mail_room/message.rb +16 -0
  28. data/lib/mail_room/microsoft_graph/connection.rb +243 -0
  29. data/lib/mail_room/microsoft_graph.rb +7 -0
  30. data/lib/mail_room/version.rb +1 -1
  31. data/lib/mail_room.rb +2 -0
  32. data/mail_room.gemspec +9 -4
  33. data/spec/fixtures/jwt_secret +1 -0
  34. data/spec/lib/arbitration/redis_spec.rb +9 -7
  35. data/spec/lib/cli_spec.rb +46 -11
  36. data/spec/lib/configuration_spec.rb +2 -3
  37. data/spec/lib/coordinator_spec.rb +11 -9
  38. data/spec/lib/crash_handler_spec.rb +42 -0
  39. data/spec/lib/delivery/letter_opener_spec.rb +12 -7
  40. data/spec/lib/delivery/logger_spec.rb +10 -11
  41. data/spec/lib/delivery/postback_spec.rb +92 -24
  42. data/spec/lib/delivery/que_spec.rb +5 -8
  43. data/spec/lib/delivery/sidekiq_spec.rb +33 -11
  44. data/spec/lib/{connection_spec.rb → imap/connection_spec.rb} +13 -17
  45. data/spec/lib/imap/message_spec.rb +36 -0
  46. data/spec/lib/jwt_spec.rb +80 -0
  47. data/spec/lib/logger/structured_spec.rb +35 -3
  48. data/spec/lib/mailbox_spec.rb +85 -34
  49. data/spec/lib/mailbox_watcher_spec.rb +54 -41
  50. data/spec/lib/message_spec.rb +35 -0
  51. data/spec/lib/microsoft_graph/connection_spec.rb +252 -0
  52. data/spec/spec_helper.rb +14 -4
  53. metadata +110 -24
  54. data/.travis.yml +0 -10
@@ -1,5 +1,6 @@
1
1
  require 'pg'
2
2
  require 'json'
3
+ require 'charlock_holmes'
3
4
 
4
5
  module MailRoom
5
6
  module Delivery
@@ -34,7 +35,7 @@ module MailRoom
34
35
  # deliver the message by pushing it onto the configured Sidekiq queue
35
36
  # @param message [String] the email message as a string, RFC822 format
36
37
  def deliver(message)
37
- queue_job(message)
38
+ queue_job(utf8_encode_message(message))
38
39
  @options.logger.info({ delivery_method: 'Que', action: 'message pushed' })
39
40
  end
40
41
 
@@ -58,6 +59,19 @@ module MailRoom
58
59
 
59
60
  connection.exec(sql, [options.priority, options.job_class, options.queue, JSON.dump(args)])
60
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
61
75
  end
62
76
  end
63
77
  end
@@ -8,16 +8,24 @@ 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
+ if namespace
22
+ warn <<~MSG
23
+ Redis namespaces are deprecated. This option will be ignored in future versions.
24
+ See https://github.com/sidekiq/sidekiq/issues/2586 for more details."
25
+ MSG
26
+ end
27
+
28
+ super(redis_url, namespace, sentinels, queue, worker, logger, redis_db)
21
29
  end
22
30
  end
23
31
 
@@ -45,7 +53,7 @@ module MailRoom
45
53
  def client
46
54
  @client ||= begin
47
55
  sentinels = options.sentinels
48
- redis_options = { url: options.redis_url }
56
+ redis_options = { url: options.redis_url, db: options.redis_db }
49
57
  redis_options[:sentinels] = sentinels if sentinels
50
58
 
51
59
  redis = ::Redis.new(redis_options)
@@ -0,0 +1,200 @@
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<Integer>] 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<Integer>] list of message ids
187
+ # @return [Array<MailRoom::IMAP::Message>] 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
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,39 @@
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
@@ -1,3 +1,4 @@
1
+ require 'date'
1
2
  require 'logger'
2
3
  require 'json'
3
4
 
@@ -10,12 +11,25 @@ module MailRoom
10
11
 
11
12
  data = {}
12
13
  data[:severity] = severity
13
- data[:time] = timestamp || Time.now.to_s
14
+ data[:time] = format_timestamp(timestamp || Time.now)
14
15
  # only accept a Hash
15
16
  data.merge!(message)
16
17
 
17
18
  data.to_json + "\n"
18
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
19
33
  end
20
34
  end
21
35
  end
@@ -1,15 +1,20 @@
1
1
  require "mail_room/delivery"
2
2
  require "mail_room/arbitration"
3
+ require "mail_room/imap"
4
+ require "mail_room/microsoft_graph"
3
5
 
4
6
  module MailRoom
5
7
  # Mailbox Configuration fields
6
8
  MAILBOX_FIELDS = [
7
9
  :email,
10
+ :inbox_method,
11
+ :inbox_options,
8
12
  :password,
9
13
  :host,
10
14
  :port,
11
15
  :ssl,
12
16
  :start_tls,
17
+ :limit_max_unread, #to avoid 'Error in IMAP command UID FETCH: Too long argument'
13
18
  :idle_timeout,
14
19
  :search_command,
15
20
  :name,
@@ -20,6 +25,7 @@ module MailRoom
20
25
  :log_path, # for logger
21
26
  :delivery_url, # for postback
22
27
  :delivery_token, # for postback
28
+ :content_type, # for postback
23
29
  :location, # for letter_opener
24
30
  :delivery_options,
25
31
  :arbitration_method,
@@ -39,23 +45,26 @@ module MailRoom
39
45
  # 29 minutes, as suggested by the spec: https://tools.ietf.org/html/rfc2177
40
46
  IMAP_IDLE_TIMEOUT = 29 * 60 # 29 minutes in in seconds
41
47
 
42
- 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
43
51
 
44
52
  # Default attributes for the mailbox configuration
45
53
  DEFAULTS = {
46
- :search_command => 'UNSEEN',
47
- :delivery_method => 'postback',
48
- :host => 'imap.gmail.com',
49
- :port => 993,
50
- :ssl => true,
51
- :start_tls => false,
52
- :idle_timeout => IMAP_IDLE_TIMEOUT,
53
- :delete_after_delivery => false,
54
- :expunge_deleted => false,
55
- :delivery_options => {},
56
- :arbitration_method => 'noop',
57
- :arbitration_options => {},
58
- :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: {}
59
68
  }
60
69
 
61
70
  # Store the configuration and require the appropriate delivery method
@@ -73,7 +82,7 @@ module MailRoom
73
82
  self[:logger]
74
83
  else
75
84
  self[:logger] ||= {}
76
- MailRoom::Logger::Structured.new(self[:logger][:log_path])
85
+ MailRoom::Logger::Structured.new(normalize_log_path(self[:logger][:log_path]))
77
86
  end
78
87
  end
79
88
 
@@ -99,13 +108,13 @@ module MailRoom
99
108
  arbitrator.deliver?(uid)
100
109
  end
101
110
 
102
- # deliver the imap email message
103
- # @param message [Net::IMAP::FetchData]
111
+ # deliver the email message
112
+ # @param message [MailRoom::Message]
104
113
  def deliver(message)
105
- body = message.attr['RFC822']
114
+ body = message.body
106
115
  return true unless body
107
116
 
108
- logger.info({context: context, uid: message.attr['UID'], action: "sending to deliverer", deliverer: delivery.class.name, byte_size: message.attr['RFC822.SIZE']})
117
+ logger.info({context: context, uid: message.uid, action: "sending to deliverer", deliverer: delivery.class.name, byte_size: body.bytesize})
109
118
  delivery.deliver(body)
110
119
  end
111
120
 
@@ -118,14 +127,32 @@ module MailRoom
118
127
  { email: self.email, name: self.name }
119
128
  end
120
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
+
121
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!
122
149
  if self[:idle_timeout] > IMAP_IDLE_TIMEOUT
123
150
  raise IdleTimeoutTooLarge,
124
151
  "Please use an idle timeout smaller than #{29*60} to prevent " \
125
152
  "IMAP server disconnects"
126
153
  end
127
154
 
128
- REQUIRED_CONFIGURATION.each do |k|
155
+ IMAP_CONFIGURATION.each do |k|
129
156
  if self[k].nil?
130
157
  raise ConfigurationError,
131
158
  "Field :#{k} is required in Mailbox: #{inspect}"
@@ -133,7 +160,23 @@ module MailRoom
133
160
  end
134
161
  end
135
162
 
136
- 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
137
180
 
138
181
  def parsed_arbitration_options
139
182
  arbitration_klass::Options.new(self)
@@ -164,5 +207,18 @@ module MailRoom
164
207
  OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
165
208
  end
166
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
167
223
  end
168
224
  end
@@ -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
 
@@ -55,8 +57,14 @@ module MailRoom
55
57
  end
56
58
 
57
59
  private
60
+
58
61
  def connection
59
- @connection ||= Connection.new(@mailbox)
62
+ @connection ||=
63
+ if @mailbox.microsoft_graph?
64
+ ::MailRoom::MicrosoftGraph::Connection.new(@mailbox)
65
+ else
66
+ ::MailRoom::IMAP::Connection.new(@mailbox)
67
+ end
60
68
  end
61
69
  end
62
70
  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