rgrove-larch 1.0.0.5 → 1.0.0.6

Sign up to get free protection for your applications and to get access to all the features.
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