rgrove-larch 1.0.0.5 → 1.0.0.6

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/README.rdoc CHANGED
@@ -54,7 +54,7 @@ option:
54
54
  larch --from imaps://mail.example.com --to imaps://imap.gmail.com \
55
55
  --from-folder "Sent Mail" --to-folder "Sent Mail" --no-create-folder
56
56
 
57
- == Server Support
57
+ == Server Compatibility
58
58
 
59
59
  Larch should work well with any server that properly supports
60
60
  IMAP4rev1[http://tools.ietf.org/html/rfc3501], and does its best to get along
@@ -79,6 +79,12 @@ more data.
79
79
  If this happens, Net::IMAP will continue waiting forever without passing control
80
80
  back to Larch, and you will need to manually kill and restart Larch.
81
81
 
82
+ == Support
83
+
84
+ The Larch mailing list is the best place for questions, comments, and discussion
85
+ about Larch. You can join the list or view the archives at
86
+ http://groups.google.com/group/larch
87
+
82
88
  == Credit
83
89
 
84
90
  The Larch::IMAP class borrows heavily from Sup[http://sup.rubyforge.org] by
data/lib/larch/errors.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  module Larch
2
2
  class Error < StandardError; end
3
+ class WatchdogException < Exception; end
3
4
 
4
5
  class IMAP
5
6
  class Error < Larch::Error; end
data/lib/larch/imap.rb CHANGED
@@ -6,7 +6,7 @@ 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
- include MonitorMixin
9
+ attr_reader :username
10
10
 
11
11
  # Maximum number of messages to fetch at once.
12
12
  MAX_FETCH_COUNT = 1024
@@ -48,21 +48,24 @@ class IMAP
48
48
  # times. Default is 3.
49
49
  #
50
50
  def initialize(uri, username, password, options = {})
51
- super()
52
-
53
51
  raise ArgumentError, "not an IMAP URI: #{uri}" unless uri.is_a?(URI) || uri =~ REGEX_URI
54
52
  raise ArgumentError, "must provide a username and password" unless username && password
55
53
  raise ArgumentError, "options must be a Hash" unless options.is_a?(Hash)
56
54
 
57
- @uri = uri.is_a?(URI) ? uri : URI(uri)
58
- @username = username
59
- @password = password
60
- @options = {:max_retries => 3}.merge(options)
55
+ @uri = uri.is_a?(URI) ? uri : URI(uri)
56
+ @username = username
57
+ @password = password
58
+ @options = {:max_retries => 3}.merge(options)
59
+
60
+ @ids = {}
61
+ @imap = nil
62
+ @last_id = 0
63
+ @last_scan = nil
64
+ @mutex = Mutex.new
61
65
 
62
- @ids = {}
63
- @imap = nil
64
- @last_id = 0
65
- @last_scan = nil
66
+ # Valid mailbox states are :closed (no mailbox open), :examined (mailbox
67
+ # open and read-only), or :selected (mailbox open and read-write).
68
+ @mailbox_state = :closed
66
69
 
67
70
  # Create private convenience methods (debug, info, warn, etc.) to make
68
71
  # logging easier.
@@ -85,17 +88,7 @@ class IMAP
85
88
  return false if has_message?(message)
86
89
 
87
90
  safely do
88
- begin
89
- @imap.select(mailbox)
90
- rescue Net::IMAP::NoResponseError => e
91
- if @options[:create_mailbox]
92
- info "creating mailbox: #{mailbox}"
93
- @imap.create(mailbox)
94
- retry
95
- end
96
-
97
- raise
98
- end
91
+ imap_select(!!@options[:create_mailbox])
99
92
 
100
93
  debug "appending message: #{message.id}"
101
94
  @imap.append(mailbox, message.rfc822, message.flags, message.internaldate)
@@ -116,16 +109,14 @@ class IMAP
116
109
  def disconnect
117
110
  return unless @imap
118
111
 
119
- synchronize do
120
- begin
121
- @imap.disconnect
122
- rescue Errno::ENOTCONN => e
123
- debug "#{e.class.name}: #{e.message}"
124
- end
125
-
126
- @imap = nil
112
+ begin
113
+ @imap.disconnect
114
+ rescue Errno::ENOTCONN => e
115
+ debug "#{e.class.name}: #{e.message}"
127
116
  end
128
117
 
118
+ reset
119
+
129
120
  info "disconnected"
130
121
  end
131
122
 
@@ -190,6 +181,10 @@ class IMAP
190
181
  mb.nil? || mb.empty? ? 'INBOX' : CGI.unescape(mb)
191
182
  end
192
183
 
184
+ def noop
185
+ safely { @imap.noop }
186
+ end
187
+
193
188
  # Same as fetch, but doesn't mark the message as seen.
194
189
  def peek(message_id)
195
190
  fetch(message_id, true)
@@ -202,50 +197,47 @@ class IMAP
202
197
 
203
198
  # Fetches message headers from the current mailbox.
204
199
  def scan_mailbox
205
- synchronize do
206
- return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
200
+ return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
207
201
 
208
- last_id = safely do
209
- begin
210
- @imap.examine(mailbox)
211
- rescue Net::IMAP::NoResponseError => e
212
- return if @options[:create_mailbox]
213
- raise FatalError, "unable to open mailbox: #{e.message}"
214
- end
202
+ begin
203
+ imap_examine
204
+ rescue Error => e
205
+ return if @options[:create_mailbox]
206
+ raise
207
+ end
215
208
 
216
- @imap.responses['EXISTS'].last
217
- end
209
+ last_id = safely { @imap.responses['EXISTS'].last }
218
210
 
219
- @last_scan = Time.now
220
- return if last_id == @last_id
211
+ @mutex.synchronize { @last_scan = Time.now }
212
+ return if last_id == @last_id
221
213
 
222
- range = (@last_id + 1)..last_id
223
- @last_id = last_id
214
+ range = (@last_id + 1)..last_id
224
215
 
225
- info "fetching message headers #{range}" <<
226
- (@options[:fast_scan] ? ' (fast scan)' : '')
216
+ @mutex.synchronize { @last_id = last_id }
227
217
 
228
- fields = if @options[:fast_scan]
229
- ['UID', 'RFC822.SIZE', 'INTERNALDATE']
230
- else
231
- "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] RFC822.SIZE INTERNALDATE)"
232
- end
218
+ info "fetching message headers #{range}" <<
219
+ (@options[:fast_scan] ? ' (fast scan)' : '')
233
220
 
234
- imap_fetch(range, fields).each do |data|
235
- id = create_id(data)
221
+ fields = if @options[:fast_scan]
222
+ ['UID', 'RFC822.SIZE', 'INTERNALDATE']
223
+ else
224
+ "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] RFC822.SIZE INTERNALDATE)"
225
+ end
236
226
 
237
- unless uid = data.attr['UID']
238
- error "UID not in IMAP response for message: #{id}"
239
- next
240
- end
227
+ imap_fetch(range, fields).each do |data|
228
+ id = create_id(data)
241
229
 
242
- if @ids.has_key?(id) && Larch.log.level == :debug
243
- envelope = imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
244
- debug "duplicate message? #{id} (Subject: #{envelope.subject})"
245
- end
230
+ unless uid = data.attr['UID']
231
+ error "UID not in IMAP response for message: #{id}"
232
+ next
233
+ end
246
234
 
247
- @ids[id] = uid
235
+ if Larch.log.level == :debug && @ids.has_key?(id)
236
+ envelope = imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
237
+ debug "duplicate message? #{id} (Subject: #{envelope.subject})"
248
238
  end
239
+
240
+ @mutex.synchronize { @ids[id] = uid }
249
241
  end
250
242
  end
251
243
 
@@ -280,6 +272,27 @@ class IMAP
280
272
  end
281
273
  end
282
274
 
275
+ # Examines the mailbox. If _force_ is true, the mailbox will be examined even
276
+ # if it is already selected (which isn't necessary unless you want to ensure
277
+ # that it's in a read-only state).
278
+ def imap_examine(force = false)
279
+ return if @mailbox_state == :examined || (!force && @mailbox_state == :selected)
280
+
281
+ safely do
282
+ begin
283
+ @mutex.synchronize { @mailbox_state = :closed }
284
+
285
+ debug "examining mailbox: #{mailbox}"
286
+ @imap.examine(mailbox)
287
+
288
+ @mutex.synchronize { @mailbox_state = :examined }
289
+
290
+ rescue Net::IMAP::NoResponseError => e
291
+ raise Error, "unable to examine mailbox: #{e.message}"
292
+ end
293
+ end
294
+ end
295
+
283
296
  # Fetches the specified _fields_ for the specified message sequence id(s) from
284
297
  # the IMAP server.
285
298
  def imap_fetch(ids, fields)
@@ -287,8 +300,10 @@ class IMAP
287
300
  data = []
288
301
  pos = 0
289
302
 
290
- safely do
291
- while pos < ids.length
303
+ while pos < ids.length
304
+ safely do
305
+ imap_examine
306
+
292
307
  data += @imap.fetch(ids[pos, MAX_FETCH_COUNT], fields)
293
308
  pos += MAX_FETCH_COUNT
294
309
  end
@@ -297,6 +312,36 @@ class IMAP
297
312
  data
298
313
  end
299
314
 
315
+ # Selects the mailbox if it is not already selected. If the mailbox does not
316
+ # exist and _create_ is +true+, it will be created. Otherwise, a
317
+ # Larch::IMAP::Error will be raised.
318
+ def imap_select(create = false)
319
+ return if @mailbox_state == :selected
320
+
321
+ safely do
322
+ begin
323
+ @mutex.synchronize { @mailbox_state = :closed }
324
+
325
+ debug "selecting mailbox: #{mailbox}"
326
+ @imap.select(mailbox)
327
+
328
+ @mutex.synchronize { @mailbox_state = :selected }
329
+
330
+ rescue Net::IMAP::NoResponseError => e
331
+ raise Error, "unable to select mailbox: #{e.message}" unless create
332
+
333
+ info "creating mailbox: #{mailbox}"
334
+
335
+ begin
336
+ @imap.create(mailbox)
337
+ retry
338
+ rescue => e
339
+ raise Error, "unable to create mailbox: #{e.message}"
340
+ end
341
+ end
342
+ end
343
+ end
344
+
300
345
  # Fetches the specified _fields_ for the specified UID(s) from the IMAP
301
346
  # server.
302
347
  def imap_uid_fetch(uids, fields)
@@ -304,8 +349,10 @@ class IMAP
304
349
  data = []
305
350
  pos = 0
306
351
 
307
- safely do
308
- while pos < uids.length
352
+ while pos < uids.length
353
+ safely do
354
+ imap_examine
355
+
309
356
  data += @imap.uid_fetch(uids[pos, MAX_FETCH_COUNT], fields)
310
357
  pos += MAX_FETCH_COUNT
311
358
  end
@@ -314,28 +361,33 @@ class IMAP
314
361
  data
315
362
  end
316
363
 
364
+ # Resets the connection and mailbox state.
365
+ def reset
366
+ @mutex.synchronize do
367
+ @imap = nil
368
+ @mailbox_state = :closed
369
+ end
370
+ end
371
+
317
372
  def safe_connect
318
- synchronize do
319
- return if @imap
373
+ return if @imap
320
374
 
321
- retries = 0
375
+ retries = 0
322
376
 
323
- begin
324
- unsafe_connect
377
+ begin
378
+ unsafe_connect
325
379
 
326
- rescue Errno::EPIPE,
327
- Errno::ETIMEDOUT,
328
- IOError,
329
- Net::IMAP::NoResponseError,
330
- OpenSSL::SSL::SSLError => e
380
+ rescue Errno::ECONNRESET,
381
+ Errno::EPIPE,
382
+ Errno::ETIMEDOUT,
383
+ OpenSSL::SSL::SSLError => e
331
384
 
332
- raise unless (retries += 1) <= @options[:max_retries]
333
- info "#{e.class.name}: #{e.message} (will retry)"
385
+ raise unless (retries += 1) <= @options[:max_retries]
386
+ info "#{e.class.name}: #{e.message} (will retry)"
334
387
 
335
- @imap = nil
336
- sleep 1 * retries
337
- retry
338
- end
388
+ reset
389
+ sleep 1 * retries
390
+ retry
339
391
  end
340
392
 
341
393
  rescue => e
@@ -347,20 +399,12 @@ class IMAP
347
399
  def safely
348
400
  safe_connect
349
401
 
350
- synchronize do
351
- # Explicitly set Net::IMAP's client thread to the current thread to ensure
352
- # that exceptions aren't raised in a dead thread.
353
- @imap.client_thread = Thread.current
354
- end
355
-
356
402
  retries = 0
357
403
 
358
404
  begin
359
405
  yield
360
406
 
361
- rescue EOFError,
362
- IOError,
363
- Errno::ECONNRESET,
407
+ rescue Errno::ECONNRESET,
364
408
  Errno::ENOTCONN,
365
409
  Errno::EPIPE,
366
410
  Errno::ETIMEDOUT,
@@ -371,7 +415,7 @@ class IMAP
371
415
 
372
416
  info "#{e.class.name}: #{e.message} (reconnecting)"
373
417
 
374
- synchronize { @imap = nil }
418
+ reset
375
419
  sleep 1 * retries
376
420
  safe_connect
377
421
  retry
@@ -388,12 +432,12 @@ class IMAP
388
432
  retry
389
433
  end
390
434
 
391
- rescue Net::IMAP::Error => e
392
- raise Error, "#{e.class.name}: #{e.message} (giving up)"
393
-
394
435
  rescue Larch::Error => e
395
436
  raise
396
437
 
438
+ rescue Net::IMAP::Error => e
439
+ raise Error, "#{e.class.name}: #{e.message} (giving up)"
440
+
397
441
  rescue => e
398
442
  raise FatalError, "#{e.class.name}: #{e.message} (cannot recover)"
399
443
  end
@@ -401,36 +445,49 @@ class IMAP
401
445
  def unsafe_connect
402
446
  info "connecting..."
403
447
 
404
- @imap = Net::IMAP.new(host, port, ssl?)
448
+ exception = nil
405
449
 
406
- info "connected on port #{port}" << (ssl? ? ' using SSL' : '')
450
+ Thread.new do
451
+ begin
452
+ @imap = Net::IMAP.new(host, port, ssl?)
407
453
 
408
- auth_methods = ['PLAIN']
409
- tried = []
454
+ info "connected on port #{port}" << (ssl? ? ' using SSL' : '')
410
455
 
411
- ['LOGIN', 'CRAM-MD5'].each do |method|
412
- auth_methods << method if @imap.capability.include?("AUTH=#{method}")
413
- end
456
+ auth_methods = ['PLAIN']
457
+ tried = []
458
+ capability = @imap.capability
414
459
 
415
- begin
416
- tried << method = auth_methods.pop
460
+ ['LOGIN', 'CRAM-MD5'].each do |method|
461
+ auth_methods << method if capability.include?("AUTH=#{method}")
462
+ end
417
463
 
418
- debug "authenticating using #{method}"
464
+ begin
465
+ tried << method = auth_methods.pop
419
466
 
420
- if method == 'PLAIN'
421
- @imap.login(@username, @password)
422
- else
423
- @imap.authenticate(method, @username, @password)
424
- end
467
+ debug "authenticating using #{method}"
425
468
 
426
- info "authenticated using #{method}"
469
+ if method == 'PLAIN'
470
+ @imap.login(@username, @password)
471
+ else
472
+ @imap.authenticate(method, @username, @password)
473
+ end
427
474
 
428
- rescue Net::IMAP::BadResponseError, Net::IMAP::NoResponseError => e
429
- debug "#{method} auth failed: #{e.message}"
430
- retry unless auth_methods.empty?
475
+ info "authenticated using #{method}"
431
476
 
432
- raise e, "#{e.message} (tried #{tried.join(', ')})"
433
- end
477
+ rescue Net::IMAP::BadResponseError, Net::IMAP::NoResponseError => e
478
+ debug "#{method} auth failed: #{e.message}"
479
+ retry unless auth_methods.empty?
480
+
481
+ raise e, "#{e.message} (tried #{tried.join(', ')})"
482
+ end
483
+
484
+ rescue => e
485
+ exception = e
486
+ error e.message
487
+ end
488
+ end.join
489
+
490
+ raise exception if exception
434
491
  end
435
492
  end
436
493
 
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.5'
3
+ APP_VERSION = '1.0.0.6'
4
4
  APP_AUTHOR = 'Ryan Grove'
5
5
  APP_EMAIL = 'ryan@wonko.com'
6
6
  APP_URL = 'http://github.com/rgrove/larch/'
data/lib/larch.rb CHANGED
@@ -45,21 +45,19 @@ module Larch
45
45
  source.connect
46
46
  dest.connect
47
47
 
48
- source_scan = Thread.new { source.scan_mailbox }
49
- dest_scan = Thread.new { dest.scan_mailbox }
50
-
51
- source_scan.join
52
- dest_scan.join
53
-
54
- source_copy = Thread.new do
48
+ source_thread = Thread.new do
55
49
  begin
50
+ source.scan_mailbox
56
51
  mutex.synchronize { @total = source.length }
57
52
 
58
53
  source.each do |id|
59
54
  next if dest.has_message?(id)
60
55
 
61
56
  begin
57
+ Thread.current[:fetching] = true
62
58
  msgq << source.peek(id)
59
+ Thread.current[:fetching] = false
60
+
63
61
  rescue Larch::IMAP::Error => e
64
62
  # TODO: Keep failed message envelopes in a buffer for later output?
65
63
  mutex.synchronize { @failed += 1 }
@@ -68,16 +66,25 @@ module Larch
68
66
  end
69
67
  end
70
68
 
69
+ rescue Larch::WatchdogException => e
70
+ Thread.current[:fetching] = false
71
+ @log.debug "#{source.username}@#{source.host}: watchdog exception"
72
+ source.noop
73
+ retry
74
+
71
75
  rescue => e
72
- @log.fatal e.message
76
+ @log.fatal "#{source.username}@#{source.host}: #{e.class.name}: #{e.message}"
77
+ Kernel.abort
73
78
 
74
79
  ensure
75
80
  msgq << :finished
76
81
  end
77
82
  end
78
83
 
79
- dest_copy = Thread.new do
84
+ dest_thread = Thread.new do
80
85
  begin
86
+ dest.scan_mailbox
87
+
81
88
  while msg = msgq.pop do
82
89
  break if msg == :finished
83
90
 
@@ -89,22 +96,56 @@ module Larch
89
96
  end
90
97
 
91
98
  @log.info "copying message: #{from} - #{msg.envelope.subject}"
99
+
100
+ Thread.current[:last_id] = msg.id
92
101
  dest << msg
93
102
 
94
103
  mutex.synchronize { @copied += 1 }
95
104
  end
96
105
 
97
- rescue Larch::IMAP::FatalError => e
98
- @log.fatal e.message
99
-
100
- rescue => e
106
+ rescue Larch::IMAP::Error => e
101
107
  mutex.synchronize { @failed += 1 }
102
108
  @log.error e.message
103
109
  retry
110
+
111
+ rescue Larch::WatchdogException => e
112
+ Thread.current[:last_id] = nil
113
+ @log.debug "#{dest.username}@#{dest.host}: watchdog exception"
114
+ dest.noop
115
+ retry
116
+
117
+ rescue => e
118
+ @log.fatal "#{dest.username}@#{dest.host}: #{e.class.name}: #{e.message}"
119
+ Kernel.abort
120
+ end
121
+ end
122
+
123
+ watchdog_thread = Thread.new do
124
+ source_flags = 0
125
+ dest_flags = 0
126
+ dest_lastid = nil
127
+
128
+ loop do
129
+ sleep 10
130
+
131
+ if msgq.length == 0 && source_thread[:fetching] && (source_flags += 1) > 1
132
+ source_flags = 0
133
+ source_thread.raise(WatchdogException)
134
+ end
135
+
136
+ if dest_thread[:last_id]
137
+ if dest_lastid == dest_thread[:last_id] && (dest_flags += 1) > 2
138
+ dest_flags = 0
139
+ dest_lastid = nil
140
+ dest_thread.raise(WatchdogException)
141
+ else
142
+ dest_lastid = dest_thread[:last_id]
143
+ end
144
+ end
104
145
  end
105
146
  end
106
147
 
107
- dest_copy.join
148
+ dest_thread.join
108
149
 
109
150
  source.disconnect
110
151
  dest.disconnect
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rgrove-larch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.5
4
+ version: 1.0.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Grove
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2009-03-21 00:00:00 -07:00
12
+ date: 2009-03-26 00:00:00 -07:00
13
13
  default_executable:
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency