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 +7 -1
- data/lib/larch/errors.rb +1 -0
- data/lib/larch/imap.rb +174 -117
- data/lib/larch/version.rb +1 -1
- data/lib/larch.rb +55 -14
- metadata +2 -2
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
|
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
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
|
-
|
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
|
58
|
-
@username
|
59
|
-
@password
|
60
|
-
@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
|
-
|
63
|
-
|
64
|
-
@
|
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
|
-
|
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
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
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
|
-
|
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
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
end
|
202
|
+
begin
|
203
|
+
imap_examine
|
204
|
+
rescue Error => e
|
205
|
+
return if @options[:create_mailbox]
|
206
|
+
raise
|
207
|
+
end
|
215
208
|
|
216
|
-
|
217
|
-
end
|
209
|
+
last_id = safely { @imap.responses['EXISTS'].last }
|
218
210
|
|
219
|
-
|
220
|
-
|
211
|
+
@mutex.synchronize { @last_scan = Time.now }
|
212
|
+
return if last_id == @last_id
|
221
213
|
|
222
|
-
|
223
|
-
@last_id = last_id
|
214
|
+
range = (@last_id + 1)..last_id
|
224
215
|
|
225
|
-
|
226
|
-
(@options[:fast_scan] ? ' (fast scan)' : '')
|
216
|
+
@mutex.synchronize { @last_id = last_id }
|
227
217
|
|
228
|
-
|
229
|
-
['
|
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
|
-
|
235
|
-
|
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
|
-
|
238
|
-
|
239
|
-
next
|
240
|
-
end
|
227
|
+
imap_fetch(range, fields).each do |data|
|
228
|
+
id = create_id(data)
|
241
229
|
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
230
|
+
unless uid = data.attr['UID']
|
231
|
+
error "UID not in IMAP response for message: #{id}"
|
232
|
+
next
|
233
|
+
end
|
246
234
|
|
247
|
-
|
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
|
-
|
291
|
-
|
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
|
-
|
308
|
-
|
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
|
-
|
319
|
-
return if @imap
|
373
|
+
return if @imap
|
320
374
|
|
321
|
-
|
375
|
+
retries = 0
|
322
376
|
|
323
|
-
|
324
|
-
|
377
|
+
begin
|
378
|
+
unsafe_connect
|
325
379
|
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
OpenSSL::SSL::SSLError => e
|
380
|
+
rescue Errno::ECONNRESET,
|
381
|
+
Errno::EPIPE,
|
382
|
+
Errno::ETIMEDOUT,
|
383
|
+
OpenSSL::SSL::SSLError => e
|
331
384
|
|
332
|
-
|
333
|
-
|
385
|
+
raise unless (retries += 1) <= @options[:max_retries]
|
386
|
+
info "#{e.class.name}: #{e.message} (will retry)"
|
334
387
|
|
335
|
-
|
336
|
-
|
337
|
-
|
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
|
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
|
-
|
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
|
-
|
448
|
+
exception = nil
|
405
449
|
|
406
|
-
|
450
|
+
Thread.new do
|
451
|
+
begin
|
452
|
+
@imap = Net::IMAP.new(host, port, ssl?)
|
407
453
|
|
408
|
-
|
409
|
-
tried = []
|
454
|
+
info "connected on port #{port}" << (ssl? ? ' using SSL' : '')
|
410
455
|
|
411
|
-
|
412
|
-
|
413
|
-
|
456
|
+
auth_methods = ['PLAIN']
|
457
|
+
tried = []
|
458
|
+
capability = @imap.capability
|
414
459
|
|
415
|
-
|
416
|
-
|
460
|
+
['LOGIN', 'CRAM-MD5'].each do |method|
|
461
|
+
auth_methods << method if capability.include?("AUTH=#{method}")
|
462
|
+
end
|
417
463
|
|
418
|
-
|
464
|
+
begin
|
465
|
+
tried << method = auth_methods.pop
|
419
466
|
|
420
|
-
|
421
|
-
@imap.login(@username, @password)
|
422
|
-
else
|
423
|
-
@imap.authenticate(method, @username, @password)
|
424
|
-
end
|
467
|
+
debug "authenticating using #{method}"
|
425
468
|
|
426
|
-
|
469
|
+
if method == 'PLAIN'
|
470
|
+
@imap.login(@username, @password)
|
471
|
+
else
|
472
|
+
@imap.authenticate(method, @username, @password)
|
473
|
+
end
|
427
474
|
|
428
|
-
|
429
|
-
debug "#{method} auth failed: #{e.message}"
|
430
|
-
retry unless auth_methods.empty?
|
475
|
+
info "authenticated using #{method}"
|
431
476
|
|
432
|
-
|
433
|
-
|
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
data/lib/larch.rb
CHANGED
@@ -45,21 +45,19 @@ module Larch
|
|
45
45
|
source.connect
|
46
46
|
dest.connect
|
47
47
|
|
48
|
-
|
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
|
-
|
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::
|
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
|
-
|
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.
|
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-
|
12
|
+
date: 2009-03-26 00:00:00 -07:00
|
13
13
|
default_executable:
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|