mail_room 0.10.0 → 0.10.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.gitlab-ci.yml +14 -12
  3. data/.rubocop.yml +5 -0
  4. data/.rubocop_todo.yml +501 -0
  5. data/.ruby-version +1 -1
  6. data/.travis.yml +12 -5
  7. data/CHANGELOG.md +4 -0
  8. data/README.md +99 -13
  9. data/Rakefile +1 -1
  10. data/lib/mail_room/arbitration/redis.rb +1 -1
  11. data/lib/mail_room/cli.rb +9 -2
  12. data/lib/mail_room/connection.rb +6 -180
  13. data/lib/mail_room/crash_handler.rb +26 -0
  14. data/lib/mail_room/delivery/letter_opener.rb +1 -1
  15. data/lib/mail_room/delivery/postback.rb +5 -3
  16. data/lib/mail_room/delivery/sidekiq.rb +4 -3
  17. data/lib/mail_room/imap/connection.rb +200 -0
  18. data/lib/mail_room/imap/message.rb +19 -0
  19. data/lib/mail_room/imap.rb +8 -0
  20. data/lib/mail_room/logger/structured.rb +15 -1
  21. data/lib/mail_room/mailbox.rb +77 -21
  22. data/lib/mail_room/mailbox_watcher.rb +9 -1
  23. data/lib/mail_room/message.rb +16 -0
  24. data/lib/mail_room/microsoft_graph/connection.rb +217 -0
  25. data/lib/mail_room/microsoft_graph.rb +7 -0
  26. data/lib/mail_room/version.rb +1 -1
  27. data/lib/mail_room.rb +2 -0
  28. data/mail_room.gemspec +7 -3
  29. data/spec/lib/arbitration/redis_spec.rb +3 -2
  30. data/spec/lib/cli_spec.rb +46 -11
  31. data/spec/lib/configuration_spec.rb +2 -3
  32. data/spec/lib/coordinator_spec.rb +11 -9
  33. data/spec/lib/crash_handler_spec.rb +42 -0
  34. data/spec/lib/delivery/letter_opener_spec.rb +10 -6
  35. data/spec/lib/delivery/logger_spec.rb +8 -10
  36. data/spec/lib/delivery/postback_spec.rb +45 -25
  37. data/spec/lib/delivery/que_spec.rb +5 -8
  38. data/spec/lib/delivery/sidekiq_spec.rb +29 -7
  39. data/spec/lib/{connection_spec.rb → imap/connection_spec.rb} +13 -17
  40. data/spec/lib/imap/message_spec.rb +36 -0
  41. data/spec/lib/logger/structured_spec.rb +35 -3
  42. data/spec/lib/mailbox_spec.rb +85 -34
  43. data/spec/lib/mailbox_watcher_spec.rb +54 -41
  44. data/spec/lib/message_spec.rb +35 -0
  45. data/spec/lib/microsoft_graph/connection_spec.rb +190 -0
  46. data/spec/spec_helper.rb +14 -4
  47. metadata +80 -21
data/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # mail_room #
2
2
 
3
- mail_room is a configuration based process that will idle on IMAP connections and execute a delivery method when a new message is received. Examples of delivery methods include:
3
+ mail_room is a configuration based process that will listen for incoming
4
+ e-mail and execute a delivery method when a new message is
5
+ received. mail_room supports the following methods for receiving e-mail:
6
+
7
+ * IMAP
8
+ * [Microsoft Graph API](https://docs.microsoft.com/en-us/graph/api/resources/mail-api-overview?view=graph-rest-1.0)
9
+
10
+ Examples of delivery methods include:
4
11
 
5
12
  * POST to a delivery URL (Postback)
6
13
  * Queue a job to Sidekiq or Que for later processing (Sidekiq or Que)
@@ -46,6 +53,7 @@ You will also need to install `faraday` or `letter_opener` if you use the `postb
46
53
  :delivery_options:
47
54
  :delivery_url: "http://localhost:3000/inbox"
48
55
  :delivery_token: "abcdefg"
56
+ :content_type: "text/plain"
49
57
 
50
58
  -
51
59
  :email: "user2@gmail.com"
@@ -93,11 +101,91 @@ You will also need to install `faraday` or `letter_opener` if you use the `postb
93
101
  :host: 127.0.0.1
94
102
  :port: 26379
95
103
  :worker: EmailReceiverWorker
104
+ -
105
+ :email: "user7@outlook365.com"
106
+ :password: "password"
107
+ :name: "inbox"
108
+ :inbox_method: microsoft_graph
109
+ :inbox_options:
110
+ :tenant_id: 12345
111
+ :client_id: ABCDE
112
+ :client_secret: YOUR-SECRET-HERE
113
+ :poll_interval: 60
114
+ :delivery_method: sidekiq
115
+ :delivery_options:
116
+ :redis_url: redis://localhost:6379
117
+ :worker: EmailReceiverWorker
96
118
  ```
97
119
 
98
120
  **Note:** If using `delete_after_delivery`, you also probably want to use
99
121
  `expunge_deleted` unless you really know what you're doing.
100
122
 
123
+ ## inbox_method
124
+
125
+ By default, IMAP mode is assumed for reading a mailbox.
126
+
127
+ ### IMAP Server Configuration ##
128
+
129
+ You can set per-mailbox configuration for the IMAP server's `host` (default: 'imap.gmail.com'), `port` (default: 993), `ssl` (default: true), and `start_tls` (default: false).
130
+
131
+ If you want to set additional options for IMAP SSL you can pass a YAML hash to match [SSLContext#set_params](http://docs.ruby-lang.org/en/2.2.0/OpenSSL/SSL/SSLContext.html#method-i-set_params). If you set `verify_mode` to `:none` it'll replace with the appropriate constant.
132
+
133
+ If you're seeing the error `Please log in via your web browser: https://support.google.com/mail/accounts/answer/78754 (Failure)`, you need to configure your Gmail account to allow less secure apps to access it: https://support.google.com/accounts/answer/6010255.
134
+
135
+ ### Microsoft Graph configuration
136
+
137
+ To use the Microsoft Graph API instead of IMAP to read e-mail, you will
138
+ need to create an application in the Azure Active Directory. See the
139
+ [Microsoft instructions](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) for more details:
140
+
141
+ 1. Sign in to the [Azure portal](https://portal.azure.com).
142
+ 1. Search for and select `Azure Active Directory`.
143
+ 1. Under `Manage`, select `App registrations` > `New registration`.
144
+ 1. Enter a `Name` for your application, such as `MailRoom`. Users of your app might see this name, and you can change it later.
145
+ 1. If `Supported account types` is listed, select the appropriate option.
146
+ 1. Leave `Redirect URI` blank. This is not needed.
147
+ 1. Select `Register`.
148
+ 1. Under `Manage`, select `Certificates & secrets`.
149
+ 1. Under `Client secrets`, select `New client secret`, and enter a name.
150
+ 1. Under `Expires`, select `Never`, unless you plan on updating the credentials every time it expires.
151
+ 1. Select `Add`. Record the secret value in a safe location for use in a later step.
152
+ 1. Under `Manage`, select `API Permissions` > `Add a permission`. Select `Microsoft Graph`.
153
+ 1. Select `Application permissions`.
154
+ 1. Under the `Mail` node, select `Mail.ReadWrite`, and then select Add permissions.
155
+ 1. If `User.Read` is listed in the permission list, you can delete this.
156
+ 1. Click `Grant admin consent` for these permissions.
157
+
158
+ #### Restrict mailbox access
159
+
160
+ Note that for MailRoom to work as a service account, this application
161
+ must have the `Mail.ReadWrite` to read/write mail in *all*
162
+ mailboxes. However, while this appears to be security risk,
163
+ we can configure an application access policy to limit the
164
+ mailbox access for this account. [Follow these instructions](https://docs.microsoft.com/en-us/graph/auth-limit-mailbox-access)
165
+ to setup PowerShell and configure this policy.
166
+
167
+ #### MailRoom config for Microsoft Graph
168
+
169
+ In the MailRoom configuration, set `inbox_method` to `microsoft_graph`.
170
+ You will also need:
171
+
172
+ * The client and tenant ID from the `Overview` section in the Azure app page
173
+ * The client secret created earlier
174
+
175
+ Fill in `inbox_options` with these values:
176
+
177
+ ```yaml
178
+ :inbox_method: microsoft_graph
179
+ :inbox_options:
180
+ :tenant_id: 12345
181
+ :client_id: ABCDE
182
+ :client_secret: YOUR-SECRET-HERE
183
+ :poll_interval: 60
184
+ ```
185
+
186
+ By default, MailRoom will poll for new messages every 60 seconds. `poll_interval` configures the number of
187
+ seconds to poll. Setting the value to 0 or under will default to 60 seconds.
188
+
101
189
  ## delivery_method ##
102
190
 
103
191
  ### postback ###
@@ -109,10 +197,10 @@ Requires `faraday` gem be installed.
109
197
  The default delivery method, requires `delivery_url` and `delivery_token` in
110
198
  configuration.
111
199
 
200
+ You can pass `content_type:` option to overwrite `faraday's` default content-type(`application/x-www-form-urlencoded`) for post requests, we recommend passing `text/plain` as content-type.
201
+
112
202
  As the postback is essentially using your app as if it were an API endpoint,
113
- you may need to disable forgery protection as you would with a JSON API. In
114
- our case, the postback is plaintext, but the protection will still need to be
115
- disabled.
203
+ you may need to disable forgery protection as you would with a JSON API.
116
204
 
117
205
  ### sidekiq ###
118
206
 
@@ -236,23 +324,18 @@ disabled, you can get the raw string of the email using `request.body.read`.
236
324
  I would recommend having the `mail` gem bundled and parse the email using
237
325
  `Mail.read_from_string(request.body.read)`.
238
326
 
327
+ *Note:* If you get the exception (`Rack::QueryParser::InvalidParameterError (invalid %-encoding...`)
328
+ it's probably because the content-type is set to Faraday's default, which is `HEADERS['content-type'] = 'application/x-www-form-urlencoded'`. It can cause `Rack` to crash due to `InvalidParameterError` exception. When you send a post with `application/x-www-form-urlencoded`, `Rack` will attempt to parse the input and can end up raising an exception, for example if the email that you are forwarding contain `%%` in its content or headers it will cause Rack to crash with the message above.
329
+
239
330
  ## idle_timeout ##
240
331
 
241
332
  By default, the IDLE command will wait for 29 minutes (in order to keep the server connection happy).
242
- If you'd prefer not to wait that long, you can pass `imap_timeout` in seconds for your mailbox configuration.
333
+ If you'd prefer not to wait that long, you can pass `idle_timeout` in seconds for your mailbox configuration.
243
334
 
244
335
  ## Search Command ##
245
336
 
246
337
  This setting allows configuration of the IMAP search command sent to the server. This still defaults 'UNSEEN'. You may find that 'NEW' works better for you.
247
338
 
248
- ## IMAP Server Configuration ##
249
-
250
- You can set per-mailbox configuration for the IMAP server's `host` (default: 'imap.gmail.com'), `port` (default: 993), `ssl` (default: true), and `start_tls` (default: false).
251
-
252
- If you want to set additional options for IMAP SSL you can pass a YAML hash to match [SSLContext#set_params](http://docs.ruby-lang.org/en/2.2.0/OpenSSL/SSL/SSLContext.html#method-i-set_params). If you set `verify_mode` to `:none` it'll replace with the appropriate constant.
253
-
254
- If you're seeing the error `Please log in via your web browser: https://support.google.com/mail/accounts/answer/78754 (Failure)`, you need to configure your Gmail account to allow less secure apps to access it: https://support.google.com/accounts/answer/6010255.
255
-
256
339
  ## Running in Production ##
257
340
 
258
341
  I suggest running with either upstart or init.d. Check out this wiki page for some example scripts for both: https://github.com/tpitale/mail_room/wiki/Init-Scripts-for-Running-mail_room
@@ -328,6 +411,9 @@ MailRoom will output JSON-formatted logs to give some observability into its ope
328
411
 
329
412
  Simply configure a `log_path` for the `logger` on any of your mailboxes. By default, nothing will be logged.
330
413
 
414
+ If you wish to log to `STDOUT` or `STDERR` instead of a file, you can pass `:stdout` or `:stderr`,
415
+ respectively and MailRoom will log there.
416
+
331
417
  ## Contributing ##
332
418
 
333
419
  1. Fork it
data/Rakefile CHANGED
@@ -3,4 +3,4 @@ require 'rspec/core/rake_task'
3
3
 
4
4
  RSpec::Core::RakeTask.new(:spec)
5
5
 
6
- task :default => :spec
6
+ task default: :spec
@@ -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
data/lib/mail_room/cli.rb CHANGED
@@ -2,14 +2,14 @@ module MailRoom
2
2
  # The CLI parses ARGV into configuration to start the coordinator with.
3
3
  # @author Tony Pitale
4
4
  class CLI
5
- attr_accessor :configuration, :coordinator
5
+ attr_accessor :configuration, :coordinator, :options
6
6
 
7
7
  # Initialize a new CLI instance to handle option parsing from arguments
8
8
  # into configuration to start the coordinator running on all mailboxes
9
9
  #
10
10
  # @param args [Array] `ARGV` passed from `bin/mail_room`
11
11
  def initialize(args)
12
- options = {}
12
+ @options = {}
13
13
 
14
14
  OptionParser.new do |parser|
15
15
  parser.banner = [
@@ -25,6 +25,10 @@ module MailRoom
25
25
  options[:quiet] = true
26
26
  end
27
27
 
28
+ parser.on('--log-exit-as') do |format|
29
+ options[:exit_error_format] = 'json' unless format.nil?
30
+ end
31
+
28
32
  # parser.on("-l", "--log FILE") do |path|
29
33
  # options[:log_path] = path
30
34
  # end
@@ -50,6 +54,9 @@ module MailRoom
50
54
  end
51
55
 
52
56
  coordinator.run
57
+ rescue Exception => e # not just Errors, but includes lower-level Exceptions
58
+ CrashHandler.new.handle(e, @options[:exit_error_format])
59
+ exit
53
60
  end
54
61
  end
55
62
  end
@@ -1,195 +1,21 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MailRoom
2
4
  class Connection
5
+ attr_reader :mailbox, :new_message_handler
6
+
3
7
  def initialize(mailbox)
4
8
  @mailbox = mailbox
5
-
6
- # log in and set the mailbox
7
- reset
8
- setup
9
9
  end
10
10
 
11
11
  def on_new_message(&block)
12
12
  @new_message_handler = block
13
13
  end
14
14
 
15
- # is the connection logged in?
16
- # @return [Boolean]
17
- def logged_in?
18
- @logged_in
19
- end
20
-
21
- # is the connection blocked idling?
22
- # @return [Boolean]
23
- def idling?
24
- @idling
25
- end
26
-
27
- # is the imap connection closed?
28
- # @return [Boolean]
29
- def disconnected?
30
- @imap.disconnected?
31
- end
32
-
33
- # is the connection ready to idle?
34
- # @return [Boolean]
35
- def ready_to_idle?
36
- logged_in? && !idling?
37
- end
38
-
39
- def quit
40
- stop_idling
41
- reset
42
- end
43
-
44
15
  def wait
45
- begin
46
- # in case we missed any between idles
47
- process_mailbox
48
-
49
- idle
50
-
51
- process_mailbox
52
- rescue Net::IMAP::Error, IOError
53
- @mailbox.logger.warn({ context: @mailbox.context, action: "Disconnected. Resetting..." })
54
- reset
55
- setup
56
- end
16
+ raise NotImplementedError
57
17
  end
58
18
 
59
- private
60
-
61
- def reset
62
- @imap = nil
63
- @logged_in = false
64
- @idling = false
65
- end
66
-
67
- def setup
68
- @mailbox.logger.info({ context: @mailbox.context, action: "Starting TLS session" })
69
- start_tls
70
-
71
- @mailbox.logger.info({ context: @mailbox.context, action: "Logging into mailbox" })
72
- log_in
73
-
74
- @mailbox.logger.info({ context: @mailbox.context, action: "Setting mailbox" })
75
- set_mailbox
76
- end
77
-
78
- # build a net/imap connection to google imap
79
- def imap
80
- @imap ||= Net::IMAP.new(@mailbox.host, :port => @mailbox.port, :ssl => @mailbox.ssl_options)
81
- end
82
-
83
- # start a TLS session
84
- def start_tls
85
- imap.starttls if @mailbox.start_tls
86
- end
87
-
88
- # send the imap login command to google
89
- def log_in
90
- imap.login(@mailbox.email, @mailbox.password)
91
- @logged_in = true
92
- end
93
-
94
- # select the mailbox name we want to use
95
- def set_mailbox
96
- imap.select(@mailbox.name) if logged_in?
97
- end
98
-
99
- # is the response for a new message?
100
- # @param response [Net::IMAP::TaggedResponse] the imap response from idle
101
- # @return [Boolean]
102
- def message_exists?(response)
103
- response.respond_to?(:name) && response.name == 'EXISTS'
104
- end
105
-
106
- # @private
107
- def idle_handler
108
- lambda {|response| imap.idle_done if message_exists?(response)}
109
- end
110
-
111
- # maintain an imap idle connection
112
- def idle
113
- return unless ready_to_idle?
114
-
115
- @mailbox.logger.info({ context: @mailbox.context, action: "Idling" })
116
- @idling = true
117
-
118
- imap.idle(@mailbox.idle_timeout, &idle_handler)
119
- ensure
120
- @idling = false
121
- end
122
-
123
- # trigger the idle to finish and wait for the thread to finish
124
- def stop_idling
125
- return unless idling?
126
-
127
- imap.idle_done
128
-
129
- # idling_thread.join
130
- # self.idling_thread = nil
131
- end
132
-
133
- def process_mailbox
134
- return unless @new_message_handler
135
- @mailbox.logger.info({ context: @mailbox.context, action: "Processing started" })
136
-
137
- msgs = new_messages
138
-
139
- any_deletions = msgs.
140
- # deliver each new message, collect success
141
- map(&@new_message_handler).
142
- # include messages with success
143
- zip(msgs).
144
- # filter failed deliveries, collect message
145
- select(&:first).map(&:last).
146
- # scrub delivered messages
147
- map { |message| scrub(message) }.
148
- any?
149
-
150
- imap.expunge if @mailbox.expunge_deleted && any_deletions
151
- end
152
-
153
- def scrub(message)
154
- if @mailbox.delete_after_delivery
155
- imap.store(message.seqno, "+FLAGS", [Net::IMAP::DELETED])
156
- true
157
- end
158
- end
159
-
160
- # @private
161
- # fetch all messages for the new message ids
162
- def new_messages
163
- # Both of these calls may results in
164
- # imap raising an EOFError, we handle
165
- # this exception in the watcher
166
- messages_for_ids(new_message_ids)
167
- end
168
-
169
- # TODO: label messages?
170
- # @imap.store(id, "+X-GM-LABELS", [label])
171
-
172
- # @private
173
- # search for all new (unseen) message ids
174
- # @return [Array<Integer>] message ids
175
- def new_message_ids
176
- # uid_search still leaves messages UNSEEN
177
- all_unread = @imap.uid_search(@mailbox.search_command)
178
-
179
- to_deliver = all_unread.select { |uid| @mailbox.deliver?(uid) }
180
- @mailbox.logger.info({ context: @mailbox.context, action: "Getting new messages", unread: {count: all_unread.count, ids: all_unread}, to_be_delivered: { count: to_deliver.count, ids: all_unread } })
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<Net::IMAP::FetchData>] 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.uid_fetch(uids, "RFC822")
193
- end
19
+ def quit; end
194
20
  end
195
21
  end
@@ -0,0 +1,26 @@
1
+ require 'date'
2
+
3
+ module MailRoom
4
+ class CrashHandler
5
+ SUPPORTED_FORMATS = %w[json none]
6
+
7
+ def initialize(stream=STDOUT)
8
+ @stream = stream
9
+ end
10
+
11
+ def handle(error, format)
12
+ if format == 'json'
13
+ @stream.puts json(error)
14
+ return
15
+ end
16
+
17
+ raise error
18
+ end
19
+
20
+ private
21
+
22
+ def json(error)
23
+ { time: DateTime.now.iso8601(3), severity: :fatal, message: error.message, backtrace: error.backtrace }.to_json
24
+ end
25
+ end
26
+ 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
@@ -5,7 +5,7 @@ module MailRoom
5
5
  # Postback Delivery method
6
6
  # @author Tony Pitale
7
7
  class Postback
8
- Options = Struct.new(:url, :token, :username, :password, :logger) do
8
+ Options = Struct.new(:url, :token, :username, :password, :logger, :content_type) do
9
9
  def initialize(mailbox)
10
10
  url =
11
11
  mailbox.delivery_url ||
@@ -22,7 +22,9 @@ module MailRoom
22
22
 
23
23
  logger = mailbox.logger
24
24
 
25
- super(url, token, username, password, logger)
25
+ content_type = mailbox.delivery_options[:content_type]
26
+
27
+ super(url, token, username, password, logger, content_type)
26
28
  end
27
29
 
28
30
  def token_auth?
@@ -59,7 +61,7 @@ module MailRoom
59
61
  request.url @delivery_options.url
60
62
  request.body = message
61
63
  # request.options[:timeout] = 3
62
- # request.headers['Content-Type'] = 'text/plain'
64
+ request.headers['Content-Type'] = @delivery_options.content_type unless @delivery_options.content_type.nil?
63
65
  end
64
66
 
65
67
  @delivery_options.logger.info({ delivery_method: 'Postback', action: 'message pushed', url: @delivery_options.url })
@@ -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)