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

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/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