rgrove-larch 1.0.2 → 1.0.2.1

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
@@ -1,6 +1,14 @@
1
1
  Larch History
2
2
  ================================================================================
3
3
 
4
+ Version 1.1.0 (git)
5
+ * Mailbox and message state information is now stored in a local SQLite
6
+ database, which allows Larch to resync and resume interrupted syncs much
7
+ more quickly without having to rescan all messages. As a result, SQLite 3 is
8
+ now a dependency.
9
+ * Progress information is now displayed regularly while scanning large
10
+ mailboxes.
11
+
4
12
  Version 1.0.2 (2009-08-05)
5
13
  * Fixed a bug that caused Larch to try to set the read-only \Recent flag on
6
14
  the destination server.
data/bin/larch CHANGED
@@ -22,7 +22,7 @@ EOS
22
22
  opt :from, "URI of the source IMAP server.", :short => '-f', :type => :string, :required => true
23
23
  opt :to, "URI of the destination IMAP server.", :short => '-t', :type => :string, :required => true
24
24
 
25
- text "\nCopy Options:"
25
+ text "\nSync Options:"
26
26
  opt :all, "Copy all folders recursively", :short => :none
27
27
  opt :all_subscribed, "Copy all subscribed folders recursively", :short => :none
28
28
  opt :exclude, "List of mailbox names/patterns that shouldn't be copied", :short => :none, :type => :strings, :multi => true
@@ -35,7 +35,8 @@ EOS
35
35
  opt :to_user, "Destination server username (default: prompt)", :short => :none, :type => :string
36
36
 
37
37
  text "\nGeneral Options:"
38
- opt :dry_run, "Don't actually make any changes.", :short => '-n'
38
+ opt :database, "Specify a non-default message database to use", :short => :none, :default => File.join('~', '.larch', 'larch.db')
39
+ opt :dry_run, "Don't actually make any changes", :short => '-n'
39
40
  opt :fast_scan, "Use a faster (but less accurate) method to scan mailboxes. This may result in messages being re-copied.", :short => :none
40
41
  opt :max_retries, "Maximum number of times to retry after a recoverable error", :short => :none, :default => 3
41
42
  opt :no_create_folder, "Don't create destination folders that don't already exist", :short => :none
@@ -104,10 +105,10 @@ EOS
104
105
  uri_to.password ||= CGI.escape(ask("Destination password (#{uri_to.host}): ") {|q| q.echo = false })
105
106
 
106
107
  # Go go go!
107
- init(
108
- options[:verbosity],
109
- options[:exclude] ? options[:exclude].flatten : [],
110
- options[:exclude_file]
108
+ init(options[:database],
109
+ :exclude => options[:exclude] ? options[:exclude].flatten : [],
110
+ :exclude_file => options[:exclude_file],
111
+ :log_level => options[:verbosity]
111
112
  )
112
113
 
113
114
  Net::IMAP.debug = true if @log.level == :insane
@@ -0,0 +1,12 @@
1
+ module Larch; module Database
2
+
3
+ class Account < Sequel::Model
4
+ plugin :hook_class_methods
5
+ one_to_many :mailboxes, :class => Larch::Database::Mailbox
6
+
7
+ before_destroy do
8
+ Mailbox.filter(:account_id => id).destroy
9
+ end
10
+ end
11
+
12
+ end; end
@@ -0,0 +1,12 @@
1
+ module Larch; module Database
2
+
3
+ class Mailbox < Sequel::Model
4
+ plugin :hook_class_methods
5
+ one_to_many :messages, :class => Larch::Database::Message
6
+
7
+ before_destroy do
8
+ Message.filter(:mailbox_id => id).destroy
9
+ end
10
+ end
11
+
12
+ end; end
@@ -0,0 +1,6 @@
1
+ module Larch; module Database
2
+
3
+ class Message < Sequel::Model
4
+ end
5
+
6
+ end; end
@@ -0,0 +1,42 @@
1
+ class CreateSchema < Sequel::Migration
2
+ def down
3
+ drop_table :accounts, :mailboxes, :messages
4
+ end
5
+
6
+ def up
7
+ create_table :accounts do
8
+ primary_key :id
9
+ text :hostname, :null => false
10
+ text :username, :null => false
11
+
12
+ unique [:hostname, :username]
13
+ end
14
+
15
+ create_table :mailboxes do
16
+ primary_key :id
17
+ foreign_key :account_id, :table => :accounts
18
+ text :name, :null => false
19
+ text :delim, :null => false
20
+ text :attr, :null => false, :default => ''
21
+ integer :subscribed, :null => false, :default => 0
22
+ integer :uidvalidity
23
+ integer :uidnext
24
+
25
+ unique [:account_id, :name, :uidvalidity]
26
+ end
27
+
28
+ create_table :messages do
29
+ primary_key :id
30
+ foreign_key :mailbox_id, :table => :mailboxes
31
+ integer :uid, :null => false
32
+ text :guid, :null => false
33
+ text :message_id
34
+ integer :rfc822_size, :null => false
35
+ integer :internaldate, :null => false
36
+ text :flags, :null => false, :default => ''
37
+
38
+ index :guid
39
+ unique [:mailbox_id, :uid]
40
+ end
41
+ end
42
+ end
@@ -2,7 +2,10 @@ module Larch; class IMAP
2
2
 
3
3
  # Represents an IMAP mailbox.
4
4
  class Mailbox
5
- attr_reader :attr, :delim, :imap, :name, :state
5
+ attr_reader :attr, :db_mailbox, :delim, :imap, :name, :state, :subscribed
6
+
7
+ # Maximum number of message headers to fetch with a single IMAP command.
8
+ FETCH_BLOCK_SIZE = 1024
6
9
 
7
10
  # Regex to capture a Message-Id header.
8
11
  REGEX_MESSAGE_ID = /message-id\s*:\s*(\S+)/i
@@ -15,12 +18,11 @@ class Mailbox
15
18
 
16
19
  @imap = imap
17
20
  @name = name
21
+ @name_utf7 = Net::IMAP.encode_utf7(@name)
18
22
  @delim = delim
19
23
  @subscribed = subscribed
20
24
  @attr = attr.flatten
21
25
 
22
- @ids = {}
23
- @last_id = 0
24
26
  @last_scan = nil
25
27
  @mutex = Mutex.new
26
28
 
@@ -28,6 +30,23 @@ class Mailbox
28
30
  # open and read-only), or :selected (mailbox open and read-write).
29
31
  @state = :closed
30
32
 
33
+ # Create/update this mailbox in the database.
34
+ mb_data = {
35
+ :name => @name,
36
+ :delim => @delim,
37
+ :attr => @attr.map{|a| a.to_s }.join(','),
38
+ :subscribed => @subscribed ? 1 : 0
39
+ }
40
+
41
+ @db_mailbox = imap.db_account.mailboxes_dataset.filter(:name => @name).first
42
+
43
+ if @db_mailbox
44
+ @db_mailbox.update(mb_data)
45
+ else
46
+ @db_mailbox = Database::Mailbox.create(mb_data)
47
+ imap.db_account.add_mailbox(@db_mailbox)
48
+ end
49
+
31
50
  # Create private convenience methods (debug, info, warn, etc.) to make
32
51
  # logging easier.
33
52
  Logger::LEVELS.each_key do |level|
@@ -46,68 +65,71 @@ class Mailbox
46
65
  # +false+ if the message already exists in the mailbox.
47
66
  def append(message)
48
67
  raise ArgumentError, "must provide a Larch::IMAP::Message object" unless message.is_a?(Larch::IMAP::Message)
49
- return false if has_message?(message)
68
+ return false if has_guid?(message.guid)
50
69
 
51
70
  @imap.safely do
52
71
  unless imap_select(!!@imap.options[:create_mailbox])
53
72
  raise Larch::IMAP::Error, "mailbox cannot contain messages: #{@name}"
54
73
  end
55
74
 
56
- debug "appending message: #{message.id}"
75
+ debug "appending message: #{message.guid}"
57
76
 
58
77
  # The \Recent flag is read-only, so we shouldn't try to set it at the
59
78
  # destination.
60
79
  flags = message.flags.dup
61
80
  flags.delete(:Recent)
62
81
 
63
- @imap.conn.append(@name, message.rfc822, flags, message.internaldate) unless @imap.options[:dry_run]
82
+ @imap.conn.append(@name_utf7, message.rfc822, flags, message.internaldate) unless @imap.options[:dry_run]
64
83
  end
65
84
 
66
85
  true
67
86
  end
68
87
  alias << append
69
88
 
70
- # Iterates through Larch message ids in this mailbox, yielding each one to the
71
- # provided block.
72
- def each
89
+ # Iterates through messages in this mailbox, yielding the Larch message guid
90
+ # of each to the provided block.
91
+ def each_guid
73
92
  scan
74
- @ids.dup.each_key {|id| yield id }
93
+ @db_mailbox.messages.each {|db_message| yield db_message.guid }
75
94
  end
76
95
 
77
- # Gets a Net::IMAP::Envelope for the specified message id.
78
- def envelope(message_id)
96
+ # Returns a Larch::IMAP::Message struct representing the message with the
97
+ # specified Larch _guid_, or +nil+ if the specified guid was not found in this
98
+ # mailbox.
99
+ def fetch(guid, peek = false)
79
100
  scan
80
- raise Larch::IMAP::MessageNotFoundError, "message not found: #{message_id}" unless uid = @ids[message_id]
81
101
 
82
- debug "fetching envelope: #{message_id}"
83
- imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
84
- end
102
+ unless db_message = @db_mailbox.messages_dataset.filter(:guid => guid).first
103
+ warn "message not found in local db: #{guid}"
104
+ return nil
105
+ end
85
106
 
86
- # Fetches a Larch::IMAP::Message struct representing the message with the
87
- # specified Larch message id.
88
- def fetch(message_id, peek = false)
89
- scan
90
- raise Larch::IMAP::MessageNotFoundError, "message not found: #{message_id}" unless uid = @ids[message_id]
107
+ debug "#{peek ? 'peeking at' : 'fetching'} message: #{guid}"
108
+
109
+ imap_uid_fetch([db_message.uid], [(peek ? 'BODY.PEEK[]' : 'BODY[]'), 'FLAGS', 'INTERNALDATE', 'ENVELOPE']) do |fetch_data|
110
+ data = fetch_data.first
111
+ check_response_fields(data, 'BODY[]', 'FLAGS', 'INTERNALDATE', 'ENVELOPE')
91
112
 
92
- debug "#{peek ? 'peeking at' : 'fetching'} message: #{message_id}"
93
- data = imap_uid_fetch([uid], [(peek ? 'BODY.PEEK[]' : 'BODY[]'), 'FLAGS', 'INTERNALDATE', 'ENVELOPE']).first
113
+ return Message.new(guid, data.attr['ENVELOPE'], data.attr['BODY[]'],
114
+ data.attr['FLAGS'], Time.parse(data.attr['INTERNALDATE']))
115
+ end
94
116
 
95
- Message.new(message_id, data.attr['ENVELOPE'], data.attr['BODY[]'],
96
- data.attr['FLAGS'], Time.parse(data.attr['INTERNALDATE']))
117
+ warn "message not found on server: #{guid}"
118
+ return nil
97
119
  end
98
120
  alias [] fetch
99
121
 
100
- # Returns +true+ if a message with the specified Larch <em>message_id</em>
101
- # exists in this mailbox, +false+ otherwise.
102
- def has_message?(message_id)
122
+ # Returns +true+ if a message with the specified Larch guid exists in this
123
+ # mailbox, +false+ otherwise.
124
+ def has_guid?(guid)
103
125
  scan
104
- @ids.has_key?(message_id)
126
+ @db_mailbox.messages_dataset.filter(:guid => guid).count > 0
105
127
  end
106
128
 
107
129
  # Gets the number of messages in this mailbox.
108
130
  def length
109
131
  scan
110
- @ids.length
132
+ @db_mailbox.messages_dataset.count
111
133
  end
112
134
  alias size length
113
135
 
@@ -124,52 +146,149 @@ class Mailbox
124
146
  # Fetches message headers from this mailbox.
125
147
  def scan
126
148
  return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
149
+ first_scan = @last_scan.nil?
150
+ @mutex.synchronize { @last_scan = Time.now }
127
151
 
152
+ # Compare the mailbox's current status with its last known status.
128
153
  begin
129
- return unless imap_examine
154
+ return unless status = imap_status('MESSAGES', 'UIDNEXT', 'UIDVALIDITY')
130
155
  rescue Error => e
131
156
  return if @imap.options[:create_mailbox]
132
157
  raise
133
158
  end
134
159
 
135
- last_id = @imap.safely { @imap.conn.responses['EXISTS'].last }
136
- @mutex.synchronize { @last_scan = Time.now }
137
- return if last_id == @last_id
160
+ flag_range = nil
161
+ full_range = nil
138
162
 
139
- range = (@last_id + 1)..last_id
140
- @mutex.synchronize { @last_id = last_id }
163
+ if @db_mailbox.uidvalidity && @db_mailbox.uidnext &&
164
+ status['UIDVALIDITY'] == @db_mailbox.uidvalidity
141
165
 
142
- info "fetching message headers #{range}" <<
143
- (@imap.options[:fast_scan] ? ' (fast scan)' : '')
166
+ # The UIDVALIDITY is the same as what we saw last time we scanned at this
167
+ # mailbox, which means that all the existing messages in the database are
168
+ # still valid. We only need to request headers for new messages.
169
+ #
170
+ # If this is the first scan of this mailbox during this Larch session,
171
+ # then we'll also update the flags of all messages in the mailbox.
172
+
173
+ flag_range = 1...@db_mailbox.uidnext if first_scan
174
+ full_range = @db_mailbox.uidnext...status['UIDNEXT']
144
175
 
145
- fields = if @imap.options[:fast_scan]
146
- ['UID', 'RFC822.SIZE', 'INTERNALDATE']
147
176
  else
148
- "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] RFC822.SIZE INTERNALDATE)"
177
+
178
+ # The UIDVALIDITY has changed or this is the first time we've scanned this
179
+ # mailbox (ever). Either way, all existing messages in the database are no
180
+ # longer valid, so we have to throw them out and re-request everything.
181
+
182
+ @db_mailbox.remove_all_messages
183
+ full_range = 1...status['UIDNEXT']
149
184
  end
150
185
 
151
- imap_fetch(range.begin..-1, fields).each do |data|
152
- id = create_id(data)
186
+ @db_mailbox.update(:uidvalidity => status['UIDVALIDITY'])
187
+
188
+ return unless flag_range || full_range.last - full_range.first > 0
189
+
190
+ # Open the mailbox for read-only access.
191
+ return unless imap_examine
192
+
193
+ if flag_range && flag_range.last - flag_range.first > 0
194
+ info "fetching latest message flags..."
195
+
196
+ expected_uids = {}
197
+ @db_mailbox.messages.each {|db_message| expected_uids[db_message.uid] = true }
153
198
 
154
- unless uid = data.attr['UID']
155
- error "UID not in IMAP response for message: #{id}"
156
- next
199
+ imap_uid_fetch(flag_range, "(UID FLAGS)", 16384) do |fetch_data|
200
+ Larch.db.transaction do
201
+ fetch_data.each do |data|
202
+ check_response_fields(data, 'UID', 'FLAGS')
203
+ expected_uids.delete(data.attr['UID'])
204
+
205
+ @db_mailbox.messages_dataset.filter(:uid => data.attr['UID']).
206
+ update(:flags => data.attr['FLAGS'].map{|f| f.to_s }.join(','))
207
+ end
208
+ end
157
209
  end
158
210
 
159
- if Larch.log.level == :debug && @ids.has_key?(id)
160
- envelope = imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
161
- debug "duplicate message? #{id} (Subject: #{envelope.subject})"
211
+ # Any UIDs that are in the database but weren't in the response have been
212
+ # deleted from the server, so we need to delete them from the database as
213
+ # well.
214
+ unless expected_uids.empty?
215
+ debug "removing #{expected_uids.length} deleted messages from the database..."
216
+
217
+ Larch.db.transaction do
218
+ expected_uids.each do |uid|
219
+ @db_mailbox.messages_dataset.filter(:uid => uid).destroy
220
+ end
221
+ end
162
222
  end
223
+ end
224
+
225
+ if full_range && full_range.last - full_range.first > 0
226
+ start = @db_mailbox.messages_dataset.count + 1
227
+ total = status['MESSAGES']
228
+ fetched = 0
229
+ progress = 0
230
+
231
+ show_progress = total - start > FETCH_BLOCK_SIZE * 4
232
+
233
+ info "fetching message headers #{start} through #{total}..."
234
+
235
+ begin
236
+ last_good_uid = nil
237
+
238
+ imap_uid_fetch(full_range, "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] RFC822.SIZE INTERNALDATE FLAGS)") do |fetch_data|
239
+ check_response_fields(fetch_data, 'UID', 'RFC822.SIZE', 'INTERNALDATE', 'FLAGS')
240
+
241
+ Larch.db.transaction do
242
+ fetch_data.each do |data|
243
+ uid = data.attr['UID']
244
+
245
+ message_data = {
246
+ :guid => create_guid(data),
247
+ :uid => uid,
248
+ :message_id => parse_message_id(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']),
249
+ :rfc822_size => data.attr['RFC822.SIZE'].to_i,
250
+ :internaldate => Time.parse(data.attr['INTERNALDATE']).to_i,
251
+ :flags => data.attr['FLAGS'].map{|f| f.to_s }.join(',')
252
+ }
253
+
254
+ @db_mailbox.add_message(Database::Message.create(message_data))
255
+ last_good_uid = uid
256
+ end
257
+
258
+ @db_mailbox.update(:uidnext => last_good_uid + 1)
259
+ end
260
+
261
+ if show_progress
262
+ fetched += fetch_data.length
263
+ last_progress = progress
264
+ progress = ((100 / (total - start).to_f) * fetched).round
265
+
266
+ info "#{progress}% complete" if progress > last_progress
267
+ end
268
+ end
163
269
 
164
- @mutex.synchronize { @ids[id] = uid }
270
+ rescue => e
271
+ # Set this mailbox's uidnext value to the last known good UID that was
272
+ # stored in the database, plus 1. This will allow Larch to resume where
273
+ # the error occurred on the next attempt rather than having to start over.
274
+ @db_mailbox.update(:uidnext => last_good_uid + 1) if last_good_uid
275
+ raise
276
+ end
165
277
  end
278
+
279
+ @db_mailbox.update(:uidnext => status['UIDNEXT'])
280
+ return
166
281
  end
167
282
 
168
283
  # Subscribes to this mailbox.
169
284
  def subscribe(force = false)
170
- return if subscribed? && !force
171
- @imap.safely { @imap.conn.subscribe(@name) } unless @imap.options[:dry_run]
285
+ return false if subscribed? && !force
286
+
287
+ @imap.safely { @imap.conn.subscribe(@name_utf7) } unless @imap.options[:dry_run]
172
288
  @mutex.synchronize { @subscribed = true }
289
+ @db_mailbox.update(:subscribed => 1)
290
+
291
+ true
173
292
  end
174
293
 
175
294
  # Returns +true+ if this mailbox is subscribed, +false+ otherwise.
@@ -179,35 +298,55 @@ class Mailbox
179
298
 
180
299
  # Unsubscribes from this mailbox.
181
300
  def unsubscribe(force = false)
182
- return unless subscribed? || force
183
- @imap.safely { @imap.conn.unsubscribe(@name) } unless @imap.options[:dry_run]
301
+ return false unless subscribed? || force
302
+
303
+ @imap.safely { @imap.conn.unsubscribe(@name_utf7) } unless @imap.options[:dry_run]
184
304
  @mutex.synchronize { @subscribed = false }
305
+ @db_mailbox.update(:subscribed => 0)
306
+
307
+ true
185
308
  end
186
309
 
187
310
  private
188
311
 
189
- # Creates an id suitable for uniquely identifying a specific message across
190
- # servers (we hope).
312
+ # Checks the specified Net::IMAP::FetchData object and raises a
313
+ # Larch::IMAP::Error unless it contains all the specified _fields_.
314
+ #
315
+ # _data_ can be a single object or an Array of objects; if it's an Array, then
316
+ # only the first object in the Array will be checked.
317
+ def check_response_fields(data, *fields)
318
+ check_data = data.is_a?(Array) ? data.first : data
319
+
320
+ fields.each do |f|
321
+ raise Error, "required data not in IMAP response: #{f}" unless check_data.attr.has_key?(f)
322
+ end
323
+
324
+ true
325
+ end
326
+
327
+ # Creates a globally unique id suitable for identifying a specific message
328
+ # on any mail server (we hope) based on the given IMAP FETCH _data_.
191
329
  #
192
330
  # If the given message data includes a valid Message-Id header, then that will
193
331
  # be used to generate an MD5 hash. Otherwise, the hash will be generated based
194
332
  # on the message's RFC822.SIZE and INTERNALDATE.
195
- def create_id(data)
196
- ['RFC822.SIZE', 'INTERNALDATE'].each do |a|
197
- raise Error, "required data not in IMAP response: #{a}" unless data.attr[a]
198
- end
199
-
200
- if data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]'] =~ REGEX_MESSAGE_ID
201
- Digest::MD5.hexdigest($1)
333
+ def create_guid(data)
334
+ if message_id = parse_message_id(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]'])
335
+ Digest::MD5.hexdigest(message_id)
202
336
  else
337
+ check_response_fields(data, 'RFC822.SIZE', 'INTERNALDATE')
338
+
203
339
  Digest::MD5.hexdigest(sprintf('%d%d', data.attr['RFC822.SIZE'],
204
340
  Time.parse(data.attr['INTERNALDATE']).to_i))
205
341
  end
206
342
  end
207
343
 
208
- # Examines the mailbox. If _force_ is true, the mailbox will be examined even
344
+ # Examines this mailbox. If _force_ is true, the mailbox will be examined even
209
345
  # if it is already selected (which isn't necessary unless you want to ensure
210
346
  # that it's in a read-only state).
347
+ #
348
+ # Returns +false+ if this mailbox cannot be examined, which may be the case if
349
+ # the \Noselect attribute is set.
211
350
  def imap_examine(force = false)
212
351
  return false if @attr.include?(:Noselect)
213
352
  return true if @state == :examined || (!force && @state == :selected)
@@ -217,7 +356,7 @@ class Mailbox
217
356
  @mutex.synchronize { @state = :closed }
218
357
 
219
358
  debug "examining mailbox"
220
- @imap.conn.examine(@name)
359
+ @imap.conn.examine(@name_utf7)
221
360
 
222
361
  @mutex.synchronize { @state = :examined }
223
362
 
@@ -229,18 +368,12 @@ class Mailbox
229
368
  return true
230
369
  end
231
370
 
232
- # Fetches the specified _fields_ for the specified _set_ of message sequence
233
- # ids (either a Range or an Array of ids).
234
- def imap_fetch(set, fields)
235
- @imap.safely do
236
- imap_examine
237
- @imap.conn.fetch(set, fields)
238
- end
239
- end
240
-
241
371
  # Selects the mailbox if it is not already selected. If the mailbox does not
242
372
  # exist and _create_ is +true+, it will be created. Otherwise, a
243
373
  # Larch::IMAP::Error will be raised.
374
+ #
375
+ # Returns +false+ if this mailbox cannot be selected, which may be the case if
376
+ # the \Noselect attribute is set.
244
377
  def imap_select(create = false)
245
378
  return false if @attr.include?(:Noselect)
246
379
  return true if @state == :selected
@@ -250,7 +383,7 @@ class Mailbox
250
383
  @mutex.synchronize { @state = :closed }
251
384
 
252
385
  debug "selecting mailbox"
253
- @imap.conn.select(@name)
386
+ @imap.conn.select(@name_utf7)
254
387
 
255
388
  @mutex.synchronize { @state = :selected }
256
389
 
@@ -260,7 +393,7 @@ class Mailbox
260
393
  info "creating mailbox: #{@name}"
261
394
 
262
395
  begin
263
- @imap.conn.create(@name) unless @imap.options[:dry_run]
396
+ @imap.conn.create(@name_utf7) unless @imap.options[:dry_run]
264
397
  retry
265
398
  rescue => e
266
399
  raise Error, "unable to create mailbox: #{e.message}"
@@ -271,15 +404,81 @@ class Mailbox
271
404
  return true
272
405
  end
273
406
 
274
- # Fetches the specified _fields_ for the specified _set_ of UIDs (either a
275
- # Range or an Array of UIDs).
276
- def imap_uid_fetch(set, fields)
407
+ # Sends an IMAP STATUS command and returns the status of the requested
408
+ # attributes. Supported attributes include:
409
+ #
410
+ # - MESSAGES
411
+ # - RECENT
412
+ # - UIDNEXT
413
+ # - UIDVALIDITY
414
+ # - UNSEEN
415
+ def imap_status(*attr)
277
416
  @imap.safely do
278
- imap_examine
279
- @imap.conn.uid_fetch(set, fields)
417
+ begin
418
+ debug "getting mailbox status"
419
+ @imap.conn.status(@name_utf7, attr)
420
+ rescue Net::IMAP::NoResponseError => e
421
+ raise Error, "unable to get status of mailbox: #{e.message}"
422
+ end
423
+ end
424
+ end
425
+
426
+ # Fetches the specified _fields_ for the specified _set_ of UIDs, which can be
427
+ # a number, Range, or Array of UIDs.
428
+ #
429
+ # If _set_ is a number, an Array containing a single Net::IMAP::FetchData
430
+ # object will be yielded to the given block.
431
+ #
432
+ # If _set_ is a Range or Array of UIDs, Arrays of up to <i>block_size</i>
433
+ # Net::IMAP::FetchData objects will be yielded until all requested messages
434
+ # have been fetched.
435
+ #
436
+ # However, if _set_ is a Range with an end value of -1, a single Array
437
+ # containing all requested messages will be yielded, since it's impossible to
438
+ # divide an infinite range into finite blocks.
439
+ def imap_uid_fetch(set, fields, block_size = FETCH_BLOCK_SIZE, &block) # :yields: fetch_data
440
+ if set.is_a?(Numeric) || (set.is_a?(Range) && set.last < 0)
441
+ data = @imap.safely do
442
+ imap_examine
443
+ @imap.conn.uid_fetch(set, fields)
444
+ end
445
+
446
+ yield data unless data.nil?
447
+ end
448
+
449
+ blocks = []
450
+ pos = 0
451
+
452
+ if set.is_a?(Array)
453
+ while pos < set.length
454
+ blocks += set[pos, block_size]
455
+ pos += block_size
456
+ end
457
+
458
+ elsif set.is_a?(Range)
459
+ pos = set.first - 1
460
+
461
+ while pos < set.last
462
+ blocks << ((pos + 1)..[set.last, pos += block_size].min)
463
+ end
464
+ end
465
+
466
+ blocks.each do |block|
467
+ data = @imap.safely do
468
+ imap_examine
469
+ @imap.conn.uid_fetch(block, fields)
470
+ end
471
+
472
+ yield data unless data.nil?
280
473
  end
281
474
  end
282
475
 
476
+ # Parses a Message-Id header out of _str_ and returns it, or +nil+ if _str_
477
+ # doesn't contain a valid Message-Id header.
478
+ def parse_message_id(str)
479
+ return str =~ REGEX_MESSAGE_ID ? $1 : nil
480
+ end
481
+
283
482
  end
284
483
 
285
484
  end; end
data/lib/larch/imap.rb CHANGED
@@ -6,19 +6,19 @@ 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
- attr_reader :conn, :options
9
+ attr_reader :conn, :db_account, :options
10
10
 
11
11
  # URI format validation regex.
12
12
  REGEX_URI = URI.regexp(['imap', 'imaps'])
13
13
 
14
14
  # Larch::IMAP::Message represents a transferable IMAP message which can be
15
15
  # passed between Larch::IMAP instances.
16
- Message = Struct.new(:id, :envelope, :rfc822, :flags, :internaldate)
16
+ Message = Struct.new(:guid, :envelope, :rfc822, :flags, :internaldate)
17
17
 
18
18
  # Initializes a new Larch::IMAP instance that will connect to the specified
19
19
  # IMAP URI.
20
20
  #
21
- # In addition to the URI, the following options may also be specified:
21
+ # In addition to the URI, the following options may be specified:
22
22
  #
23
23
  # [:create_mailbox]
24
24
  # If +true+, mailboxes that don't already exist will be created if
@@ -62,9 +62,14 @@ class IMAP
62
62
 
63
63
  raise ArgumentError, "must provide a username and password" unless @uri.user && @uri.password
64
64
 
65
- @conn = nil
66
- @mailboxes = {}
67
- @mutex = Mutex.new
65
+ @conn = nil
66
+ @mailboxes = {}
67
+ @mutex = Mutex.new
68
+
69
+ @db_account = Database::Account.find_or_create(
70
+ :hostname => host,
71
+ :username => username
72
+ )
68
73
 
69
74
  # Create private convenience methods (debug, info, warn, etc.) to make
70
75
  # logging easier.
@@ -170,7 +175,8 @@ class IMAP
170
175
  begin
171
176
  yield
172
177
 
173
- rescue Errno::ECONNRESET,
178
+ rescue Errno::ECONNABORTED,
179
+ Errno::ECONNRESET,
174
180
  Errno::ENOTCONN,
175
181
  Errno::EPIPE,
176
182
  Errno::ETIMEDOUT,
@@ -332,6 +338,11 @@ class IMAP
332
338
  subscribed.any?{|s| s.name == mb.name}, mb.attr)
333
339
  end
334
340
  end
341
+
342
+ # Remove mailboxes that no longer exist from the database.
343
+ @db_account.mailboxes.each do |db_mailbox|
344
+ db_mailbox.destroy unless @mailboxes.has_key?(db_mailbox.name)
345
+ end
335
346
  end
336
347
 
337
348
  end
data/lib/larch/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Larch
2
2
  APP_NAME = 'Larch'
3
- APP_VERSION = '1.0.2'
3
+ APP_VERSION = '1.0.2.1'
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
@@ -4,10 +4,14 @@ $:.uniq!
4
4
 
5
5
  require 'cgi'
6
6
  require 'digest/md5'
7
+ require 'fileutils'
7
8
  require 'net/imap'
8
9
  require 'time'
9
10
  require 'uri'
10
11
 
12
+ require 'sequel'
13
+ require 'sequel/extensions/migration'
14
+
11
15
  require 'larch/errors'
12
16
  require 'larch/imap'
13
17
  require 'larch/imap/mailbox'
@@ -17,16 +21,23 @@ require 'larch/version'
17
21
  module Larch
18
22
 
19
23
  class << self
20
- attr_reader :log, :exclude
24
+ attr_reader :db, :log, :exclude
21
25
 
22
26
  EXCLUDE_COMMENT = /#.*$/
23
27
  EXCLUDE_REGEX = /^\s*\/(.*)\/\s*/
24
28
  GLOB_PATTERNS = {'*' => '.*', '?' => '.'}
29
+ LIB_DIR = File.join(File.dirname(File.expand_path(__FILE__)), 'larch')
30
+
31
+ def init(database, config = {})
32
+ @config = {
33
+ :exclude => [],
34
+ :log_level => :info
35
+ }.merge(config)
25
36
 
26
- def init(log_level = :info, exclude = [], exclude_file = nil)
27
- @log = Logger.new(log_level)
37
+ @log = Logger.new(@config[:log_level])
38
+ @db = open_db(database)
28
39
 
29
- @exclude = exclude.map do |e|
40
+ @exclude = @config[:exclude].map do |e|
30
41
  if e =~ EXCLUDE_REGEX
31
42
  Regexp.new($1, Regexp::IGNORECASE)
32
43
  else
@@ -34,7 +45,7 @@ module Larch
34
45
  end
35
46
  end
36
47
 
37
- load_exclude_file(exclude_file) if exclude_file
48
+ load_exclude_file(@config[:exclude_file]) if @config[:exclude_file]
38
49
 
39
50
  # Stats
40
51
  @copied = 0
@@ -59,7 +70,7 @@ module Larch
59
70
  mailbox_to = imap_to.mailbox(mailbox_from.name, mailbox_from.delim)
60
71
  mailbox_to.subscribe if mailbox_from.subscribed?
61
72
 
62
- copy_messages(imap_from, mailbox_from, imap_to, mailbox_to)
73
+ copy_messages(imap_from, mailbox_from.name, imap_to, mailbox_to.name)
63
74
  end
64
75
 
65
76
  rescue => e
@@ -79,13 +90,12 @@ module Larch
79
90
  @failed = 0
80
91
  @total = 0
81
92
 
82
- from_name = imap_from.uri_mailbox || 'INBOX'
83
- to_name = imap_to.uri_mailbox || 'INBOX'
93
+ from_mb_name = imap_from.uri_mailbox || 'INBOX'
94
+ to_mb_name = imap_to.uri_mailbox || 'INBOX'
84
95
 
85
- return if excluded?(from_name) || excluded?(to_name)
96
+ return if excluded?(from_mb_name) || excluded?(to_mb_name)
86
97
 
87
- copy_messages(imap_from, imap_from.mailbox(from_name), imap_to,
88
- imap_to.mailbox(to_name))
98
+ copy_messages(imap_from, from_mb_name, imap_to, to_mb_name)
89
99
 
90
100
  imap_from.disconnect
91
101
  imap_to.disconnect
@@ -97,32 +107,70 @@ module Larch
97
107
  summary
98
108
  end
99
109
 
110
+ # Opens a connection to the Larch message database, creating it if
111
+ # necessary.
112
+ def open_db(database)
113
+ filename = File.expand_path(database)
114
+ directory = File.dirname(filename)
115
+
116
+ unless File.exist?(directory)
117
+ FileUtils.mkdir_p(directory)
118
+ File.chmod(0700, directory)
119
+ end
120
+
121
+ begin
122
+ db = Sequel.connect("sqlite://#{filename}")
123
+ db.test_connection
124
+ rescue => e
125
+ @log.fatal "unable to open message database: #{e}"
126
+ abort
127
+ end
128
+
129
+ # Ensure that the database schema is up to date.
130
+ migration_dir = File.join(LIB_DIR, 'db', 'migrate')
131
+
132
+ unless Sequel::Migrator.get_current_migration_version(db) ==
133
+ Sequel::Migrator.latest_migration_version(migration_dir)
134
+ begin
135
+ Sequel::Migrator.apply(db, migration_dir)
136
+ rescue => e
137
+ @log.fatal "unable to migrate message database: #{e}"
138
+ abort
139
+ end
140
+ end
141
+
142
+ require 'larch/db/message'
143
+ require 'larch/db/mailbox'
144
+ require 'larch/db/account'
145
+
146
+ db
147
+ end
148
+
100
149
  def summary
101
150
  @log.info "#{@copied} message(s) copied, #{@failed} failed, #{@total - @copied - @failed} untouched out of #{@total} total"
102
151
  end
103
152
 
104
153
  private
105
154
 
106
- def copy_messages(imap_from, mailbox_from, imap_to, mailbox_to)
155
+ def copy_messages(imap_from, mb_name_from, imap_to, mb_name_to)
107
156
  raise ArgumentError, "imap_from must be a Larch::IMAP instance" unless imap_from.is_a?(IMAP)
108
- raise ArgumentError, "mailbox_from must be a Larch::IMAP::Mailbox instance" unless mailbox_from.is_a?(IMAP::Mailbox)
109
157
  raise ArgumentError, "imap_to must be a Larch::IMAP instance" unless imap_to.is_a?(IMAP)
110
- raise ArgumentError, "mailbox_to must be a Larch::IMAP::Mailbox instance" unless mailbox_to.is_a?(IMAP::Mailbox)
111
158
 
112
- return if excluded?(mailbox_from.name) || excluded?(mailbox_to.name)
159
+ return if excluded?(mb_name_from) || excluded?(mb_name_to)
113
160
 
114
- @log.info "copying messages from #{imap_from.host}/#{mailbox_from.name} to #{imap_to.host}/#{mailbox_to.name}"
161
+ @log.info "copying messages from #{imap_from.host}/#{mb_name_from} to #{imap_to.host}/#{mb_name_to}"
115
162
 
116
- imap_from.connect
117
- imap_to.connect
163
+ mailbox_from = imap_from.mailbox(mb_name_from)
118
164
 
119
165
  @total += mailbox_from.length
120
166
 
121
- mailbox_from.each do |id|
122
- next if mailbox_to.has_message?(id)
167
+ mailbox_to = imap_to.mailbox(mb_name_to)
168
+
169
+ mailbox_from.each_guid do |guid|
170
+ next if mailbox_to.has_guid?(guid)
123
171
 
124
172
  begin
125
- msg = mailbox_from.peek(id)
173
+ next unless msg = mailbox_from.peek(guid)
126
174
 
127
175
  if msg.envelope.from
128
176
  env_from = msg.envelope.from.first
@@ -137,7 +185,6 @@ module Larch
137
185
  @copied += 1
138
186
 
139
187
  rescue Larch::IMAP::Error => e
140
- # TODO: Keep failed message envelopes in a buffer for later output?
141
188
  @failed += 1
142
189
  @log.error e.message
143
190
  next
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.2
4
+ version: 1.0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Grove
@@ -9,8 +9,8 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2009-05-16 00:00:00 -07:00
13
- default_executable:
12
+ date: 2009-08-17 00:00:00 -07:00
13
+ default_executable: larch
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: highline
@@ -22,6 +22,26 @@ dependencies:
22
22
  - !ruby/object:Gem::Version
23
23
  version: 1.5.0
24
24
  version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: sequel
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ~>
32
+ - !ruby/object:Gem::Version
33
+ version: 3.3.0
34
+ version:
35
+ - !ruby/object:Gem::Dependency
36
+ name: sqlite3-ruby
37
+ type: :runtime
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: 1.2.5
44
+ version:
25
45
  - !ruby/object:Gem::Dependency
26
46
  name: trollop
27
47
  type: :runtime
@@ -45,12 +65,16 @@ files:
45
65
  - LICENSE
46
66
  - README.rdoc
47
67
  - bin/larch
48
- - lib/larch.rb
68
+ - lib/larch/db/account.rb
69
+ - lib/larch/db/mailbox.rb
70
+ - lib/larch/db/message.rb
71
+ - lib/larch/db/migrate/001_create_schema.rb
49
72
  - lib/larch/errors.rb
50
- - lib/larch/imap.rb
51
73
  - lib/larch/imap/mailbox.rb
74
+ - lib/larch/imap.rb
52
75
  - lib/larch/logger.rb
53
76
  - lib/larch/version.rb
77
+ - lib/larch.rb
54
78
  has_rdoc: false
55
79
  homepage: http://github.com/rgrove/larch/
56
80
  licenses:
@@ -76,7 +100,7 @@ requirements: []
76
100
  rubyforge_project:
77
101
  rubygems_version: 1.3.5
78
102
  signing_key:
79
- specification_version: 2
103
+ specification_version: 3
80
104
  summary: Larch syncs messages from one IMAP server to another. Awesomely.
81
105
  test_files: []
82
106