larch 1.0.0 → 1.0.1

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.
data/lib/larch/imap.rb CHANGED
@@ -6,38 +6,29 @@ module Larch
6
6
  # required reading if you're doing anything with IMAP in Ruby:
7
7
  # http://sup.rubyforge.org
8
8
  class IMAP
9
-
10
- # Recoverable connection errors.
11
- RECOVERABLE_ERRORS = [
12
- Errno::EPIPE,
13
- Errno::ETIMEDOUT,
14
- Net::IMAP::NoResponseError,
15
- OpenSSL::SSL::SSLError
16
- ]
17
-
18
- # Regex to capture the individual fields in an IMAP fetch command.
19
- REGEX_FIELDS = /([0-9A-Z\.]+\[[^\]]+\](?:<[0-9\.]+>)?|[0-9A-Z\.]+)/
20
-
21
- # Regex to capture a Message-Id header.
22
- REGEX_MESSAGE_ID = /message-id\s*:\s*(\S+)/i
9
+ attr_reader :conn, :options
23
10
 
24
11
  # URI format validation regex.
25
12
  REGEX_URI = URI.regexp(['imap', 'imaps'])
26
13
 
27
- # Minimum time (in seconds) allowed between mailbox scans.
28
- SCAN_INTERVAL = 60
29
-
30
14
  # Larch::IMAP::Message represents a transferable IMAP message which can be
31
15
  # passed between Larch::IMAP instances.
32
16
  Message = Struct.new(:id, :envelope, :rfc822, :flags, :internaldate)
33
17
 
34
18
  # Initializes a new Larch::IMAP instance that will connect to the specified
35
- # IMAP URI and authenticate using the specified _username_ and _password_.
19
+ # IMAP URI.
36
20
  #
37
- # The following options may also be specified:
21
+ # In addition to the URI, the following options may also be specified:
38
22
  #
39
23
  # [:create_mailbox]
40
- # If +true+, the specified mailbox will be created if necessary.
24
+ # If +true+, mailboxes that don't already exist will be created if
25
+ # necessary.
26
+ #
27
+ # [:dry_run]
28
+ # If +true+, read-only operations will be performed as usual and all change
29
+ # operations will be simulated, but no changes will actually be made. Note
30
+ # that it's not actually possible to simulate mailbox creation, so
31
+ # +:dry_run+ mode always behaves as if +:create_mailbox+ is +false+.
41
32
  #
42
33
  # [:fast_scan]
43
34
  # If +true+, a faster but less accurate method will be used to scan
@@ -51,29 +42,36 @@ class IMAP
51
42
  # After a recoverable error occurs, retry the operation up to this many
52
43
  # times. Default is 3.
53
44
  #
54
- def initialize(uri, username, password, options = {})
45
+ # [:ssl_certs]
46
+ # Path to a trusted certificate bundle to use to verify server SSL
47
+ # certificates. You can download a bundle of certificate authority root
48
+ # certs at http://curl.haxx.se/ca/cacert.pem (it's up to you to verify that
49
+ # this bundle hasn't been tampered with, however; don't trust it blindly).
50
+ #
51
+ # [:ssl_verify]
52
+ # If +true+, server SSL certificates will be verified against the trusted
53
+ # certificate bundle specified in +ssl_certs+. By default, server SSL
54
+ # certificates are not verified.
55
+ #
56
+ def initialize(uri, options = {})
55
57
  raise ArgumentError, "not an IMAP URI: #{uri}" unless uri.is_a?(URI) || uri =~ REGEX_URI
56
- raise ArgumentError, "must provide a username and password" unless username && password
57
58
  raise ArgumentError, "options must be a Hash" unless options.is_a?(Hash)
58
59
 
59
- @uri = uri.is_a?(URI) ? uri : URI(uri)
60
- @username = username
61
- @password = password
62
- @options = {:max_retries => 3}.merge(options)
60
+ @options = {:max_retries => 3, :ssl_verify => false}.merge(options)
61
+ @uri = uri.is_a?(URI) ? uri : URI(uri)
63
62
 
64
- @ids = {}
65
- @imap = nil
66
- @last_id = 0
67
- @last_scan = nil
68
- @message_ids = nil
69
- @mutex = Mutex.new
63
+ raise ArgumentError, "must provide a username and password" unless @uri.user && @uri.password
64
+
65
+ @conn = nil
66
+ @mailboxes = {}
67
+ @mutex = Mutex.new
70
68
 
71
69
  # Create private convenience methods (debug, info, warn, etc.) to make
72
70
  # logging easier.
73
71
  Logger::LEVELS.each_key do |level|
74
72
  IMAP.class_eval do
75
73
  define_method(level) do |msg|
76
- Larch.log.log(level, "#{@username}@#{host}: #{msg}")
74
+ Larch.log.log(level, "#{username}@#{host}: #{msg}")
77
75
  end
78
76
 
79
77
  private level
@@ -81,120 +79,80 @@ class IMAP
81
79
  end
82
80
  end
83
81
 
84
- # Appends the specified Larch::IMAP::Message to this mailbox if it doesn't
85
- # already exist. Returns +true+ if the message was appended successfully,
86
- # +false+ if the message already exists in the mailbox.
87
- def append(message)
88
- raise ArgumentError, "must provide a Larch::IMAP::Message object" unless message.is_a?(Message)
89
- return false if has_message?(message)
90
-
91
- safely do
92
- @mutex.synchronize do
93
- begin
94
- @imap.select(mailbox)
95
- rescue Net::IMAP::NoResponseError => e
96
- if @options[:create_mailbox]
97
- info "creating mailbox: #{mailbox}"
98
- @imap.create(mailbox)
99
- retry
100
- end
101
-
102
- raise
103
- end
104
- end
105
-
106
- debug "appending message: #{message.id}"
107
- @imap.append(mailbox, message.rfc822, message.flags, message.internaldate)
108
- end
109
-
110
- true
111
- end
112
- alias << append
113
-
114
82
  # Connects to the IMAP server and logs in if a connection hasn't already been
115
83
  # established.
116
84
  def connect
117
- return if @imap
85
+ return if @conn
118
86
  safely {} # connect, but do nothing else
119
87
  end
120
88
 
89
+ # Gets the server's mailbox hierarchy delimiter.
90
+ def delim
91
+ @delim ||= safely { @conn.list('', '')[0].delim }
92
+ end
93
+
121
94
  # Closes the IMAP connection if one is currently open.
122
95
  def disconnect
123
- return unless @imap
96
+ return unless @conn
124
97
 
125
- @imap.disconnect
126
- @imap = nil
98
+ begin
99
+ @conn.disconnect
100
+ rescue Errno::ENOTCONN => e
101
+ debug "#{e.class.name}: #{e.message}"
102
+ end
103
+
104
+ reset
127
105
 
128
106
  info "disconnected"
129
107
  end
130
- synchronized :disconnect
131
-
132
- # Iterates through Larch message ids in this mailbox, yielding each one to the
133
- # provided block.
134
- def each
135
- ids = @mutex.synchronize do
136
- unsync_scan_mailbox
137
- @ids
138
- end
139
108
 
140
- ids.each_key {|id| yield id }
109
+ # Iterates through all mailboxes in the account, yielding each one as a
110
+ # Larch::IMAP::Mailbox instance to the given block.
111
+ def each_mailbox
112
+ update_mailboxes
113
+ @mailboxes.each_value {|mailbox| yield mailbox }
141
114
  end
142
115
 
143
- # Gets a Net::IMAP::Envelope for the specified message id.
144
- def envelope(message_id)
145
- scan_mailbox
146
- uid = @ids[message_id]
147
-
148
- raise NotFoundError, "message not found: #{message_id}" if uid.nil?
149
-
150
- debug "fetching envelope: #{message_id}"
151
- imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
116
+ # Gets the IMAP hostname.
117
+ def host
118
+ @uri.host
152
119
  end
153
120
 
154
- # Fetches a Larch::IMAP::Message struct representing the message with the
155
- # specified Larch message id.
156
- def fetch(message_id, peek = false)
157
- scan_mailbox
158
- uid = @ids[message_id]
159
-
160
- raise NotFoundError, "message not found: #{message_id}" if uid.nil?
121
+ # Gets a Larch::IMAP::Mailbox instance representing the specified mailbox. If
122
+ # the mailbox doesn't exist and the <tt>:create_mailbox</tt> option is
123
+ # +false+, or if <tt>:create_mailbox</tt> is +true+ and mailbox creation
124
+ # fails, a Larch::IMAP::MailboxNotFoundError will be raised.
125
+ def mailbox(name, delim = '/')
126
+ retries = 0
161
127
 
162
- debug "#{peek ? 'peeking at' : 'fetching'} message: #{message_id}"
163
- data = imap_uid_fetch([uid], [(peek ? 'BODY.PEEK[]' : 'BODY[]'), 'FLAGS', 'INTERNALDATE', 'ENVELOPE']).first
128
+ name = name.gsub(delim, self.delim)
164
129
 
165
- Message.new(message_id, data.attr['ENVELOPE'], data.attr['BODY[]'],
166
- data.attr['FLAGS'], Time.parse(data.attr['INTERNALDATE']))
167
- end
168
- alias [] fetch
130
+ begin
131
+ @mailboxes.fetch(name) do
132
+ update_mailboxes
133
+ return @mailboxes[name] if @mailboxes.has_key?(name)
134
+ raise MailboxNotFoundError, "mailbox not found: #{name}"
135
+ end
169
136
 
170
- # Returns +true+ if a message with the specified Larch <em>message_id</em>
171
- # exists in this mailbox, +false+ otherwise.
172
- def has_message?(message_id)
173
- scan_mailbox
174
- @ids.has_key?(message_id)
175
- end
137
+ rescue MailboxNotFoundError => e
138
+ raise unless @options[:create_mailbox] && retries == 0
176
139
 
177
- # Gets the IMAP hostname.
178
- def host
179
- @uri.host
180
- end
140
+ info "creating mailbox: #{name}"
141
+ safely { @conn.create(name) } unless @options[:dry_run]
181
142
 
182
- # Gets the number of messages in this mailbox.
183
- def length
184
- scan_mailbox
185
- @ids.length
143
+ retries += 1
144
+ retry
145
+ end
186
146
  end
187
- alias size length
188
147
 
189
- # Gets the IMAP mailbox.
190
- def mailbox
191
- mb = @uri.path[1..-1]
192
- mb.nil? || mb.empty? ? 'INBOX' : CGI.unescape(mb)
148
+ # Sends an IMAP NOOP command.
149
+ def noop
150
+ safely { @conn.noop }
193
151
  end
194
152
 
195
- # Same as fetch, but doesn't mark the message as seen.
196
- def peek(message_id)
197
- fetch(message_id, true)
153
+ # Gets the IMAP password.
154
+ def password
155
+ CGI.unescape(@uri.password)
198
156
  end
199
157
 
200
158
  # Gets the IMAP port number.
@@ -202,53 +160,53 @@ class IMAP
202
160
  @uri.port || (ssl? ? 993 : 143)
203
161
  end
204
162
 
205
- # Fetches message headers from the current mailbox.
206
- def scan_mailbox
207
- return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
163
+ # Connect if necessary, execute the given block, retry if a recoverable error
164
+ # occurs, die if an unrecoverable error occurs.
165
+ def safely
166
+ safe_connect
208
167
 
209
- last_id = safely do
210
- begin
211
- @imap.examine(mailbox)
212
- rescue Net::IMAP::NoResponseError => e
213
- return if @options[:create_mailbox]
214
- raise FatalError, "unable to open mailbox: #{e.message}"
215
- end
168
+ retries = 0
216
169
 
217
- @imap.responses['EXISTS'].last
218
- end
170
+ begin
171
+ yield
219
172
 
220
- @last_scan = Time.now
221
- return if last_id == @last_id
173
+ rescue Errno::ECONNRESET,
174
+ Errno::ENOTCONN,
175
+ Errno::EPIPE,
176
+ Errno::ETIMEDOUT,
177
+ Net::IMAP::ByeResponseError,
178
+ OpenSSL::SSL::SSLError => e
222
179
 
223
- range = (@last_id + 1)..last_id
224
- @last_id = last_id
180
+ raise unless (retries += 1) <= @options[:max_retries]
225
181
 
226
- info "fetching message headers #{range}" <<
227
- (@options[:fast_scan] ? ' (fast scan)' : '')
182
+ info "#{e.class.name}: #{e.message} (reconnecting)"
228
183
 
229
- fields = if @options[:fast_scan]
230
- ['UID', 'RFC822.SIZE', 'INTERNALDATE']
231
- else
232
- "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] RFC822.SIZE INTERNALDATE)"
233
- end
184
+ reset
185
+ sleep 1 * retries
186
+ safe_connect
187
+ retry
234
188
 
235
- imap_fetch(range, fields).each do |data|
236
- id = create_id(data)
189
+ rescue Net::IMAP::BadResponseError,
190
+ Net::IMAP::NoResponseError,
191
+ Net::IMAP::ResponseParseError => e
237
192
 
238
- unless uid = data.attr['UID']
239
- error "UID not in IMAP response for message: #{id}"
240
- next
241
- end
193
+ raise unless (retries += 1) <= @options[:max_retries]
242
194
 
243
- if @ids.has_key?(id) && Larch.log.level == :debug
244
- envelope = imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
245
- debug "duplicate message? #{id} (Subject: #{envelope.subject})"
246
- end
195
+ info "#{e.class.name}: #{e.message} (will retry)"
247
196
 
248
- @ids[id] = uid
197
+ sleep 1 * retries
198
+ retry
249
199
  end
200
+
201
+ rescue Larch::Error => e
202
+ raise
203
+
204
+ rescue Net::IMAP::Error => e
205
+ raise Error, "#{e.class.name}: #{e.message} (giving up)"
206
+
207
+ rescue => e
208
+ raise FatalError, "#{e.class.name}: #{e.message} (cannot recover)"
250
209
  end
251
- synchronized :scan_mailbox
252
210
 
253
211
  # Gets the SSL status.
254
212
  def ssl?
@@ -260,111 +218,55 @@ class IMAP
260
218
  @uri.to_s
261
219
  end
262
220
 
263
- private
264
-
265
- # Creates an id suitable for uniquely identifying a specific message across
266
- # servers (we hope).
267
- #
268
- # If the given message data includes a valid Message-Id header, then that will
269
- # be used to generate an MD5 hash. Otherwise, the hash will be generated based
270
- # on the message's RFC822.SIZE and INTERNALDATE.
271
- def create_id(data)
272
- ['RFC822.SIZE', 'INTERNALDATE'].each do |a|
273
- raise FatalError, "requested data not in IMAP response: #{a}" unless data.attr[a]
274
- end
275
-
276
- if data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]'] =~ REGEX_MESSAGE_ID
277
- Digest::MD5.hexdigest($1)
278
- else
279
- Digest::MD5.hexdigest(sprintf('%d%d', data.attr['RFC822.SIZE'],
280
- Time.parse(data.attr['INTERNALDATE']).to_i))
281
- end
221
+ # Gets the IMAP mailbox specified in the URI, or +nil+ if none.
222
+ def uri_mailbox
223
+ mb = @uri.path[1..-1]
224
+ mb.nil? || mb.empty? ? nil : CGI.unescape(mb)
282
225
  end
283
226
 
284
- # Fetches the specified _fields_ for the specified message sequence id(s) from
285
- # the IMAP server.
286
- def imap_fetch(ids, fields)
287
- data = safely { @imap.fetch(ids, fields) }
288
-
289
- # If fields isn't an array, make it one.
290
- fields = REGEX_FIELDS.match(fields).captures unless fields.is_a?(Array)
291
-
292
- # Translate BODY.PEEK to BODY in fields, since that's how it'll come back in
293
- # the response.
294
- fields.map! {|f| f.sub(/^BODY\.PEEK\[/, 'BODY[') }
295
-
296
- good_results = ids.respond_to?(:member?) ?
297
- data.find_all {|i| ids.member?(i.seqno) && fields.all? {|f| i.attr.member?(f) }} :
298
- data.find_all {|i| ids == i.seqno && fields.all? {|f| i.attr.member?(f) }}
299
-
300
- if good_results.empty?
301
- raise FatalError, "0 out of #{data.length} items in IMAP response for message(s) #{ids} contained all requested fields: #{fields.join(', ')}"
302
- elsif good_results.length < data.length
303
- error "IMAP server sent #{good_results.length} results in response to a request for #{data.length} messages"
304
- end
305
-
306
- good_results
227
+ # Gets the IMAP username.
228
+ def username
229
+ CGI.unescape(@uri.user)
307
230
  end
308
231
 
309
- # Fetches the specified _fields_ for the specified UID(s) from the IMAP
310
- # server.
311
- def imap_uid_fetch(uids, fields)
312
- data = safely { @imap.uid_fetch(uids, fields) }
313
-
314
- # If fields isn't an array, make it one.
315
- fields = REGEX_FIELDS.match(fields).captures unless fields.is_a?(Array)
316
-
317
- # Translate BODY.PEEK to BODY in fields, since that's how it'll come back in
318
- # the response.
319
- fields.map! {|f| f.sub(/^BODY\.PEEK\[/, 'BODY[') }
320
-
321
- good_results = data.find_all do |i|
322
- i.attr.member?('UID') && uids.member?(i.attr['UID']) &&
323
- fields.all? {|f| i.attr.member?(f) }
324
- end
232
+ private
325
233
 
326
- if good_results.empty?
327
- raise FatalError, "0 out of #{data.length} items in IMAP response for message UID(s) #{uids.join(', ')} contained all requested fields: #{fields.join(', ')}"
328
- elsif good_results.length < data.length
329
- error "IMAP server sent #{good_results.length} results in response to a request for #{data.length} messages"
234
+ # Resets the connection and mailbox state.
235
+ def reset
236
+ @mutex.synchronize do
237
+ @conn = nil
238
+ @mailboxes.each_value {|mb| mb.reset }
330
239
  end
331
-
332
- good_results
333
240
  end
334
241
 
335
- # Connect if necessary, execute the given block, retry up to 3 times if a
336
- # recoverable error occurs, die if an unrecoverable error occurs.
337
- def safely
242
+ def safe_connect
243
+ return if @conn
244
+
338
245
  retries = 0
339
246
 
340
247
  begin
341
- unsafe_connect unless @imap
342
- rescue *RECOVERABLE_ERRORS => e
343
- info "#{e.class.name}: #{e.message} (will retry)"
344
- raise unless (retries += 1) <= @options[:max_retries]
248
+ unsafe_connect
345
249
 
346
- @imap = nil
347
- sleep 1 * retries
348
- retry
349
- end
250
+ rescue Errno::ECONNRESET,
251
+ Errno::EPIPE,
252
+ Errno::ETIMEDOUT,
253
+ OpenSSL::SSL::SSLError => e
350
254
 
351
- retries = 0
255
+ raise unless (retries += 1) <= @options[:max_retries]
256
+
257
+ # Special check to ensure that we don't retry on OpenSSL certificate
258
+ # verification errors.
259
+ raise if e.is_a?(OpenSSL::SSL::SSLError) && e.message =~ /certificate verify failed/
352
260
 
353
- begin
354
- yield
355
- rescue *RECOVERABLE_ERRORS => e
356
261
  info "#{e.class.name}: #{e.message} (will retry)"
357
- raise unless (retries += 1) <= @options[:max_retries]
358
262
 
263
+ reset
359
264
  sleep 1 * retries
360
265
  retry
361
266
  end
362
267
 
363
- rescue Net::IMAP::NoResponseError => e
364
- raise Error, "#{e.class.name}: #{e.message} (giving up)"
365
-
366
- rescue IOError, Net::IMAP::Error, OpenSSL::SSL::SSLError, SocketError, SystemCallError => e
367
- raise FatalError, "#{e.class.name}: #{e.message} (giving up)"
268
+ rescue => e
269
+ raise FatalError, "#{e.class.name}: #{e.message} (cannot recover)"
368
270
  end
369
271
 
370
272
  def unsafe_connect
@@ -374,15 +276,18 @@ class IMAP
374
276
 
375
277
  Thread.new do
376
278
  begin
377
- @imap = Net::IMAP.new(host, port, ssl?)
279
+ @conn = Net::IMAP.new(host, port, ssl?,
280
+ ssl? && @options[:ssl_verify] ? @options[:ssl_certs] : nil,
281
+ @options[:ssl_verify])
378
282
 
379
283
  info "connected on port #{port}" << (ssl? ? ' using SSL' : '')
380
284
 
381
285
  auth_methods = ['PLAIN']
382
286
  tried = []
287
+ capability = @conn.capability
383
288
 
384
289
  ['LOGIN', 'CRAM-MD5'].each do |method|
385
- auth_methods << method if @imap.capability.include?("AUTH=#{method}")
290
+ auth_methods << method if capability.include?("AUTH=#{method}")
386
291
  end
387
292
 
388
293
  begin
@@ -391,9 +296,9 @@ class IMAP
391
296
  debug "authenticating using #{method}"
392
297
 
393
298
  if method == 'PLAIN'
394
- @imap.login(@username, @password)
299
+ @conn.login(username, password)
395
300
  else
396
- @imap.authenticate(method, @username, @password)
301
+ @conn.authenticate(method, username, password)
397
302
  end
398
303
 
399
304
  info "authenticated using #{method}"
@@ -407,12 +312,28 @@ class IMAP
407
312
 
408
313
  rescue => e
409
314
  exception = e
410
- error e.message
411
315
  end
412
316
  end.join
413
317
 
414
318
  raise exception if exception
415
319
  end
320
+
321
+ def update_mailboxes
322
+ all = safely { @conn.list('', '*') } || []
323
+ subscribed = safely { @conn.lsub('', '*') } || []
324
+
325
+ @mutex.synchronize do
326
+ # Remove cached mailboxes that no longer exist.
327
+ @mailboxes.delete_if {|k, v| !all.any?{|mb| mb.name == k}}
328
+
329
+ # Update cached mailboxes.
330
+ all.each do |mb|
331
+ @mailboxes[mb.name] ||= Mailbox.new(self, mb.name, mb.delim,
332
+ subscribed.any?{|s| s.name == mb.name}, mb.attr)
333
+ end
334
+ end
335
+ end
336
+
416
337
  end
417
338
 
418
339
  end
data/lib/larch/logger.rb CHANGED
@@ -4,11 +4,12 @@ class Logger
4
4
  attr_reader :level, :output
5
5
 
6
6
  LEVELS = {
7
- :fatal => 0,
8
- :error => 1,
9
- :warn => 2,
10
- :info => 3,
11
- :debug => 4
7
+ :fatal => 0,
8
+ :error => 1,
9
+ :warn => 2,
10
+ :info => 3,
11
+ :debug => 4,
12
+ :insane => 5
12
13
  }
13
14
 
14
15
  def initialize(level = :info, output = $stdout)
data/lib/larch/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Larch
2
2
  APP_NAME = 'Larch'
3
- APP_VERSION = '1.0.0'
3
+ APP_VERSION = '1.0.1'
4
4
  APP_AUTHOR = 'Ryan Grove'
5
5
  APP_EMAIL = 'ryan@wonko.com'
6
6
  APP_URL = 'http://github.com/rgrove/larch/'