larch 1.1.0.dev.20091106 → 1.1.0.dev.20091123

Sign up to get free protection for your applications and to get access to all the features.
data/HISTORY CHANGED
@@ -17,8 +17,13 @@ Version 1.1.0 (git)
17
17
  mailboxes.
18
18
  * Added short versions of common command-line options.
19
19
  * The --fast-scan option has been removed.
20
+ * The --to-folder option can now be used in conjunction with --all or
21
+ --all-subscribed to copy messages from multiple source folders to a single
22
+ destination folder.
20
23
  * Fixed encoding issues when creating mailboxes and getting mailbox lists.
21
24
  * Fixed incorrect case-sensitive treatment of the 'INBOX' folder name.
25
+ * Fixed a bug in which Larch would try to copy flags that weren't supported on
26
+ the destination server.
22
27
 
23
28
  Version 1.0.2 (2009-08-05)
24
29
  * Fixed a bug that caused Larch to try to set the read-only \Recent flag on
data/bin/larch CHANGED
@@ -21,11 +21,11 @@ Usage:
21
21
  Server Options:
22
22
  EOS
23
23
  opt :from, "URI of the source IMAP server.", :short => '-f', :type => :string
24
- opt :from_folder, "Source folder to copy from", :short => '-F', :default => Config::DEFAULT['from-folder']
24
+ opt :from_folder, "Source folder to copy from (default: INBOX)", :short => '-F', :default => Config::DEFAULT['from-folder'], :type => :string
25
25
  opt :from_pass, "Source server password (default: prompt)", :short => '-p', :type => :string
26
26
  opt :from_user, "Source server username (default: prompt)", :short => '-u', :type => :string
27
27
  opt :to, "URI of the destination IMAP server.", :short => '-t', :type => :string
28
- opt :to_folder, "Destination folder to copy to", :short => '-T', :default => Config::DEFAULT['to-folder']
28
+ opt :to_folder, "Destination folder to copy to (default: INBOX)", :short => '-T', :default => Config::DEFAULT['to-folder'], :type => :string
29
29
  opt :to_pass, "Destination server password (default: prompt)", :short => '-P', :type => :string
30
30
  opt :to_user, "Destination server username (default: prompt)", :short => '-U', :type => :string
31
31
 
@@ -46,16 +46,13 @@ EOS
46
46
  opt :verbosity, "Output verbosity: debug, info, warn, error, or fatal", :short => '-V', :default => Config::DEFAULT['verbosity']
47
47
  end
48
48
 
49
- # Load config.
50
- config = Config.new(ARGV.shift || 'default', options[:config], options)
51
-
52
49
  if options[:config_given]
53
50
  Trollop.die :config, ": file not found: #{options[:config]}" unless File.exist?(options[:config])
54
51
  end
55
52
 
56
- # Validate config.
53
+ # Load config.
57
54
  begin
58
- config.validate
55
+ config = Config.new(ARGV.shift || 'default', options[:config], options)
59
56
  rescue Config::Error => e
60
57
  abort "Config error: #{e}"
61
58
  end
@@ -65,13 +62,12 @@ EOS
65
62
  uri_to = URI(config.to)
66
63
 
67
64
  # Use --from-folder and --to-folder unless folders were specified in the URIs.
68
- uri_from.path = uri_from.path.empty? ? '/' + CGI.escape(config.from_folder.gsub(/^\//, '')) : uri_from.path
69
- uri_to.path = uri_to.path.empty? ? '/' + CGI.escape(config.to_folder.gsub(/^\//, '')) : uri_to.path
65
+ uri_from.path = uri_from.path.empty? ? '/' + CGI.escape(config.from_folder.gsub(/^\//, '')) : uri_from.path if config.from_folder
66
+ uri_to.path = uri_to.path.empty? ? '/' + CGI.escape(config.to_folder.gsub(/^\//, '')) : uri_to.path if config.to_folder
70
67
 
71
68
  # --all and --all-subscribed options override folders
72
69
  if config.all || config.all_subscribed
73
70
  uri_from.path = ''
74
- uri_to.path = ''
75
71
  end
76
72
 
77
73
  # Usernames and passwords specified as arguments override those in the URIs
data/lib/larch/config.rb CHANGED
@@ -12,7 +12,7 @@ class Config
12
12
  'exclude' => [],
13
13
  'exclude-file' => nil,
14
14
  'from' => nil,
15
- 'from-folder' => 'INBOX',
15
+ 'from-folder' => nil, # actually INBOX; see validate()
16
16
  'from-pass' => nil,
17
17
  'from-user' => nil,
18
18
  'max-retries' => 3,
@@ -20,7 +20,7 @@ class Config
20
20
  'ssl-certs' => nil,
21
21
  'ssl-verify' => false,
22
22
  'to' => nil,
23
- 'to-folder' => 'INBOX',
23
+ 'to-folder' => nil, # actually INBOX; see validate()
24
24
  'to-pass' => nil,
25
25
  'to-user' => nil,
26
26
  'verbosity' => 'info'
@@ -36,6 +36,7 @@ class Config
36
36
  end
37
37
 
38
38
  load_file(filename)
39
+ validate
39
40
  end
40
41
 
41
42
  def fetch(name)
@@ -64,6 +65,7 @@ class Config
64
65
  fetch(name)
65
66
  end
66
67
 
68
+ # Validates the config and resolves conflicting settings.
67
69
  def validate
68
70
  ['from', 'to'].each do |s|
69
71
  raise Error, "'#{s}' must be a valid IMAP URI (e.g. imap://example.com)" unless fetch(s) =~ IMAP::REGEX_URI
@@ -77,6 +79,22 @@ class Config
77
79
  raise Error, "exclude file not found: #{exclude_file}" unless File.file?(exclude_file)
78
80
  raise Error, "exclude file cannot be read: #{exclude_file}" unless File.readable?(exclude_file)
79
81
  end
82
+
83
+ if @cached['all'] || @cached['all-subscribed']
84
+ # A specific source folder wins over 'all' and 'all-subscribed'
85
+ if @cached['from-folder']
86
+ @cached['all'] = false
87
+ @cached['all-subscribed'] = false
88
+ @cached['to-folder'] ||= @cached['from-folder']
89
+
90
+ elsif @cached['all'] && @cached['all-subscribed']
91
+ # 'all' wins over 'all-subscribed'
92
+ @cached['all-subscribed'] = false
93
+ end
94
+ else
95
+ @cached['from-folder'] ||= 'INBOX'
96
+ @cached['to-folder'] ||= 'INBOX'
97
+ end
80
98
  end
81
99
 
82
100
  private
@@ -1,6 +1,6 @@
1
1
  module Larch; module Database
2
2
 
3
- class Account < Sequel::Model
3
+ class Account < Sequel::Model(:accounts)
4
4
  plugin :hook_class_methods
5
5
  one_to_many :mailboxes, :class => Larch::Database::Mailbox
6
6
 
@@ -1,6 +1,6 @@
1
1
  module Larch; module Database
2
2
 
3
- class Mailbox < Sequel::Model
3
+ class Mailbox < Sequel::Model(:mailboxes)
4
4
  plugin :hook_class_methods
5
5
  one_to_many :messages, :class => Larch::Database::Message
6
6
 
@@ -1,6 +1,6 @@
1
1
  module Larch; module Database
2
2
 
3
- class Message < Sequel::Model
3
+ class Message < Sequel::Model(:messages)
4
4
  end
5
5
 
6
6
  end; end
@@ -2,7 +2,7 @@ module Larch; class IMAP
2
2
 
3
3
  # Represents an IMAP mailbox.
4
4
  class Mailbox
5
- attr_reader :attr, :db_mailbox, :delim, :imap, :name, :state, :subscribed
5
+ attr_reader :attr, :db_mailbox, :delim, :flags, :imap, :name, :perm_flags, :state, :subscribed
6
6
 
7
7
  # Maximum number of message headers to fetch with a single IMAP command.
8
8
  FETCH_BLOCK_SIZE = 1024
@@ -20,6 +20,8 @@ class Mailbox
20
20
  @name = name
21
21
  @name_utf7 = Net::IMAP.encode_utf7(@name)
22
22
  @delim = delim
23
+ @flags = []
24
+ @perm_flags = []
23
25
  @subscribed = subscribed
24
26
  @attr = attr.flatten
25
27
 
@@ -72,13 +74,20 @@ class Mailbox
72
74
  raise Larch::IMAP::Error, "mailbox cannot contain messages: #{@name}"
73
75
  end
74
76
 
75
- debug "appending message: #{message.guid}"
76
-
77
- # The \Recent flag is read-only, so we shouldn't try to set it at the
78
- # destination.
79
77
  flags = message.flags.dup
80
- flags.delete(:Recent)
81
78
 
79
+ # Don't set any flags that aren't supported on the destination mailbox.
80
+ flags.delete_if do |flag|
81
+ # The \Recent flag is read-only, so we shouldn't try to set it.
82
+ next true if flag == :Recent
83
+
84
+ unless @flags.include?(flag) || @perm_flags.include?(:*) || @perm_flags.include?(flag)
85
+ debug "flag not supported on destination: #{flag}"
86
+ true
87
+ end
88
+ end
89
+
90
+ debug "appending message: #{message.guid}"
82
91
  @imap.conn.append(@name_utf7, message.rfc822, flags, message.internaldate) unless @imap.options[:dry_run]
83
92
  end
84
93
 
@@ -90,7 +99,7 @@ class Mailbox
90
99
  # of each to the provided block.
91
100
  def each_guid # :yields: guid
92
101
  scan
93
- @db_mailbox.messages.each {|db_message| yield db_message.guid }
102
+ @db_mailbox.messages_dataset.each {|db_message| yield db_message.guid }
94
103
  end
95
104
 
96
105
  # Iterates through mailboxes that are first-level children of this mailbox,
@@ -152,8 +161,8 @@ class Mailbox
152
161
  end
153
162
 
154
163
  # Same as fetch, but doesn't mark the message as seen.
155
- def peek(message_id)
156
- fetch(message_id, true)
164
+ def peek(guid)
165
+ fetch(guid, true)
157
166
  end
158
167
 
159
168
  # Resets the mailbox state.
@@ -211,17 +220,31 @@ class Mailbox
211
220
  if flag_range && flag_range.last - flag_range.first > 0
212
221
  info "fetching latest message flags..."
213
222
 
223
+ # Load the expected UIDs and their flags into a Hash for quicker lookups.
214
224
  expected_uids = {}
215
- @db_mailbox.messages.each {|db_message| expected_uids[db_message.uid] = true }
225
+ @db_mailbox.messages_dataset.each do |db_message|
226
+ expected_uids[db_message.uid] = db_message.flags.split(',').map{|f| f.to_sym }
227
+ end
216
228
 
217
229
  imap_uid_fetch(flag_range, "(UID FLAGS)", 16384) do |fetch_data|
230
+ # Check the fields in the first response to ensure that everything we
231
+ # asked for is there.
232
+ check_response_fields(fetch_data.first, 'UID', 'FLAGS') unless fetch_data.empty?
233
+
218
234
  Larch.db.transaction do
219
235
  fetch_data.each do |data|
220
- check_response_fields(data, 'UID', 'FLAGS')
221
- expected_uids.delete(data.attr['UID'])
236
+ uid = data.attr['UID']
237
+ flags = data.attr['FLAGS']
238
+ local_flags = expected_uids[uid]
239
+
240
+ # If we haven't seen this message before, or if its flags have
241
+ # changed, update the database.
242
+ unless local_flags && local_flags == flags
243
+ @db_mailbox.messages_dataset.filter(:uid => uid).update(
244
+ :flags => flags.map{|f| f.to_s }.join(','))
245
+ end
222
246
 
223
- @db_mailbox.messages_dataset.filter(:uid => data.attr['UID']).
224
- update(:flags => data.attr['FLAGS'].map{|f| f.to_s }.join(','))
247
+ expected_uids.delete(uid)
225
248
  end
226
249
  end
227
250
  end
@@ -233,7 +256,7 @@ class Mailbox
233
256
  debug "removing #{expected_uids.length} deleted messages from the database..."
234
257
 
235
258
  Larch.db.transaction do
236
- expected_uids.each do |uid|
259
+ expected_uids.each_key do |uid|
237
260
  @db_mailbox.messages_dataset.filter(:uid => uid).destroy
238
261
  end
239
262
  end
@@ -378,6 +401,7 @@ class Mailbox
378
401
 
379
402
  debug "examining mailbox"
380
403
  @imap.conn.examine(@name_utf7)
404
+ refresh_flags
381
405
 
382
406
  @mutex.synchronize { @state = :examined }
383
407
 
@@ -405,6 +429,7 @@ class Mailbox
405
429
 
406
430
  debug "selecting mailbox"
407
431
  @imap.conn.select(@name_utf7)
432
+ refresh_flags
408
433
 
409
434
  @mutex.synchronize { @state = :selected }
410
435
 
@@ -487,7 +512,18 @@ class Mailbox
487
512
  blocks.each do |block|
488
513
  data = @imap.safely do
489
514
  imap_examine
490
- @imap.conn.uid_fetch(block, fields)
515
+
516
+ begin
517
+ data = @imap.conn.uid_fetch(block, fields)
518
+
519
+ rescue Net::IMAP::NoResponseError => e
520
+ raise unless e.message == 'Some messages could not be FETCHed (Failure)'
521
+
522
+ # Workaround for stupid Gmail shenanigans.
523
+ warn "Gmail error: '#{e.message}'; continuing anyway"
524
+ end
525
+
526
+ next data
491
527
  end
492
528
 
493
529
  yield data unless data.nil?
@@ -500,6 +536,15 @@ class Mailbox
500
536
  return str =~ REGEX_MESSAGE_ID ? $1 : nil
501
537
  end
502
538
 
539
+ # Refreshes the list of valid flags for this mailbox.
540
+ def refresh_flags
541
+ return unless @imap.conn.responses.has_key?('FLAGS') &&
542
+ @imap.conn.responses.has_key?('PERMANENTFLAGS')
543
+
544
+ @flags = Array(@imap.conn.responses['FLAGS'].first)
545
+ @perm_flags = Array(@imap.conn.responses['PERMANENTFLAGS'].first)
546
+ end
547
+
503
548
  end
504
549
 
505
550
  end; end
data/lib/larch/imap.rb CHANGED
@@ -54,9 +54,9 @@ class IMAP
54
54
 
55
55
  raise ArgumentError, "must provide a username and password" unless @uri.user && @uri.password
56
56
 
57
- @conn = nil
58
- @mailboxes = {}
59
- @mutex = Mutex.new
57
+ @conn = nil
58
+ @mailboxes = {}
59
+ @mutex = Mutex.new
60
60
 
61
61
  @db_account = Database::Account.find_or_create(
62
62
  :hostname => host,
@@ -173,6 +173,7 @@ class IMAP
173
173
  Errno::ENOTCONN,
174
174
  Errno::EPIPE,
175
175
  Errno::ETIMEDOUT,
176
+ IOError,
176
177
  Net::IMAP::ByeResponseError,
177
178
  OpenSSL::SSL::SSLError => e
178
179
 
data/lib/larch/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Larch
2
2
  APP_NAME = 'Larch'
3
- APP_VERSION = '1.1.0.dev.20091106'
3
+ APP_VERSION = '1.1.0.dev.20091123'
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
@@ -69,7 +69,12 @@ module Larch
69
69
  next if excluded?(mailbox_from.name)
70
70
  next if subscribed_only && !mailbox_from.subscribed?
71
71
 
72
- mailbox_to = imap_to.mailbox(mailbox_from.name, mailbox_from.delim)
72
+ if imap_to.uri_mailbox
73
+ mailbox_to = imap_to.mailbox(imap_to.uri_mailbox)
74
+ else
75
+ mailbox_to = imap_to.mailbox(mailbox_from.name, mailbox_from.delim)
76
+ end
77
+
73
78
  mailbox_to.subscribe if mailbox_from.subscribed?
74
79
 
75
80
  copy_messages(mailbox_from, mailbox_to)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: larch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0.dev.20091106
4
+ version: 1.1.0.dev.20091123
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-11-06 00:00:00 -08:00
12
+ date: 2009-11-23 00:00:00 -08:00
13
13
  default_executable:
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
@@ -30,7 +30,7 @@ dependencies:
30
30
  requirements:
31
31
  - - ~>
32
32
  - !ruby/object:Gem::Version
33
- version: 3.3.0
33
+ version: 3.6.0
34
34
  version:
35
35
  - !ruby/object:Gem::Dependency
36
36
  name: sqlite3-ruby