segfault-larch 1.0.2.3

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.
@@ -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
+ Larch::Database::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
@@ -0,0 +1,14 @@
1
+ module Larch
2
+ class Error < StandardError; end
3
+
4
+ class Config
5
+ class Error < Larch::Error; end
6
+ end
7
+
8
+ class IMAP
9
+ class Error < Larch::Error; end
10
+ class FatalError < Error; end
11
+ class MailboxNotFoundError < Error; end
12
+ class MessageNotFoundError < Error; end
13
+ end
14
+ end
data/lib/larch/imap.rb ADDED
@@ -0,0 +1,343 @@
1
+ module Larch
2
+
3
+ # Manages a connection to an IMAP server and all the glorious fun that entails.
4
+ #
5
+ # This class borrows heavily from Sup, the source code of which should be
6
+ # required reading if you're doing anything with IMAP in Ruby:
7
+ # http://sup.rubyforge.org
8
+ class IMAP
9
+ attr_reader :conn, :db_account, :mailboxes, :options
10
+
11
+ # URI format validation regex.
12
+ REGEX_URI = URI.regexp(['imap', 'imaps'])
13
+
14
+ # Larch::IMAP::Message represents a transferable IMAP message which can be
15
+ # passed between Larch::IMAP instances.
16
+ Message = Struct.new(:guid, :envelope, :rfc822, :flags, :internaldate)
17
+
18
+ # Initializes a new Larch::IMAP instance that will connect to the specified
19
+ # IMAP URI.
20
+ #
21
+ # In addition to the URI, the following options may be specified:
22
+ #
23
+ # [:create_mailbox]
24
+ # If +true+, mailboxes that don't already exist will be created if
25
+ # necessary.
26
+ #
27
+ # [:dry_run]
28
+ # If +true+, read-only operations will be performed as usual and all change
29
+ # operations will be simulated, but no changes will actually be made. Note
30
+ # that it's not actually possible to simulate mailbox creation, so
31
+ # +:dry_run+ mode always behaves as if +:create_mailbox+ is +false+.
32
+ #
33
+ # [:max_retries]
34
+ # After a recoverable error occurs, retry the operation up to this many
35
+ # times. Default is 3.
36
+ #
37
+ # [:ssl_certs]
38
+ # Path to a trusted certificate bundle to use to verify server SSL
39
+ # certificates. You can download a bundle of certificate authority root
40
+ # certs at http://curl.haxx.se/ca/cacert.pem (it's up to you to verify that
41
+ # this bundle hasn't been tampered with, however; don't trust it blindly).
42
+ #
43
+ # [:ssl_verify]
44
+ # If +true+, server SSL certificates will be verified against the trusted
45
+ # certificate bundle specified in +ssl_certs+. By default, server SSL
46
+ # certificates are not verified.
47
+ #
48
+ def initialize(uri, options = {})
49
+ raise ArgumentError, "not an IMAP URI: #{uri}" unless uri.is_a?(URI) || uri =~ REGEX_URI
50
+ raise ArgumentError, "options must be a Hash" unless options.is_a?(Hash)
51
+
52
+ @options = {:max_retries => 3, :ssl_verify => false}.merge(options)
53
+ @uri = uri.is_a?(URI) ? uri : URI(uri)
54
+
55
+ raise ArgumentError, "must provide a username and password" unless @uri.user && @uri.password
56
+
57
+ @conn = nil
58
+ @mailboxes = {}
59
+ @mutex = Mutex.new
60
+
61
+ @db_account = Database::Account.find_or_create(
62
+ :hostname => host,
63
+ :username => username
64
+ )
65
+
66
+ # Create private convenience methods (debug, info, warn, etc.) to make
67
+ # logging easier.
68
+ Logger::LEVELS.each_key do |level|
69
+ IMAP.class_eval do
70
+ define_method(level) do |msg|
71
+ Larch.log.log(level, "#{username}@#{host}: #{msg}")
72
+ end
73
+
74
+ private level
75
+ end
76
+ end
77
+ end
78
+
79
+ # Connects to the IMAP server and logs in if a connection hasn't already been
80
+ # established.
81
+ def connect
82
+ return if @conn
83
+ safely {} # connect, but do nothing else
84
+ end
85
+
86
+ # Gets the server's mailbox hierarchy delimiter.
87
+ def delim
88
+ @delim ||= safely { @conn.list('', '')[0].delim }
89
+ end
90
+
91
+ # Closes the IMAP connection if one is currently open.
92
+ def disconnect
93
+ return unless @conn
94
+
95
+ begin
96
+ @conn.disconnect
97
+ rescue Errno::ENOTCONN => e
98
+ debug "#{e.class.name}: #{e.message}"
99
+ end
100
+
101
+ reset
102
+
103
+ info "disconnected"
104
+ end
105
+
106
+ # Iterates through all mailboxes in the account, yielding each one as a
107
+ # Larch::IMAP::Mailbox instance to the given block.
108
+ def each_mailbox
109
+ update_mailboxes
110
+ @mailboxes.each_value {|mailbox| yield mailbox }
111
+ end
112
+
113
+ # Gets the IMAP hostname.
114
+ def host
115
+ @uri.host
116
+ end
117
+
118
+ # Gets a Larch::IMAP::Mailbox instance representing the specified mailbox. If
119
+ # the mailbox doesn't exist and the <tt>:create_mailbox</tt> option is
120
+ # +false+, or if <tt>:create_mailbox</tt> is +true+ and mailbox creation
121
+ # fails, a Larch::IMAP::MailboxNotFoundError will be raised.
122
+ def mailbox(name, delim = '/')
123
+ retries = 0
124
+
125
+ name = name.gsub(delim, self.delim)
126
+
127
+ begin
128
+ @mailboxes.fetch(name) do
129
+ update_mailboxes
130
+ return @mailboxes[name] if @mailboxes.has_key?(name)
131
+ raise MailboxNotFoundError, "mailbox not found: #{name}"
132
+ end
133
+
134
+ rescue MailboxNotFoundError => e
135
+ raise unless @options[:create_mailbox] && retries == 0
136
+
137
+ info "creating mailbox: #{name}"
138
+ safely { @conn.create(name) } unless @options[:dry_run]
139
+
140
+ retries += 1
141
+ retry
142
+ end
143
+ end
144
+
145
+ # Sends an IMAP NOOP command.
146
+ def noop
147
+ safely { @conn.noop }
148
+ end
149
+
150
+ # Gets the IMAP password.
151
+ def password
152
+ CGI.unescape(@uri.password)
153
+ end
154
+
155
+ # Gets the IMAP port number.
156
+ def port
157
+ @uri.port || (ssl? ? 993 : 143)
158
+ end
159
+
160
+ # Connect if necessary, execute the given block, retry if a recoverable error
161
+ # occurs, die if an unrecoverable error occurs.
162
+ def safely
163
+ safe_connect
164
+
165
+ retries = 0
166
+
167
+ begin
168
+ yield
169
+
170
+ rescue Errno::ECONNABORTED,
171
+ Errno::ECONNRESET,
172
+ Errno::ENOTCONN,
173
+ Errno::EPIPE,
174
+ Errno::ETIMEDOUT,
175
+ EOFError,
176
+ Net::IMAP::ByeResponseError,
177
+ OpenSSL::SSL::SSLError => e
178
+
179
+ raise unless (retries += 1) <= @options[:max_retries]
180
+
181
+ info "#{e.class.name}: #{e.message} (reconnecting)"
182
+
183
+ reset
184
+ sleep 1 * retries
185
+ safe_connect
186
+ retry
187
+
188
+ rescue Net::IMAP::BadResponseError,
189
+ Net::IMAP::NoResponseError,
190
+ Net::IMAP::ResponseParseError => e
191
+
192
+ raise unless (retries += 1) <= @options[:max_retries]
193
+
194
+ info "#{e.class.name}: #{e.message} (will retry)"
195
+
196
+ sleep 1 * retries
197
+ retry
198
+ end
199
+
200
+ rescue Larch::Error => e
201
+ raise
202
+
203
+ rescue Net::IMAP::Error => e
204
+ raise Error, "#{e.class.name}: #{e.message} (giving up)"
205
+
206
+ rescue => e
207
+ raise FatalError, "#{e.class.name}: #{e.message} (cannot recover)"
208
+ end
209
+
210
+ # Gets the SSL status.
211
+ def ssl?
212
+ @uri.scheme == 'imaps'
213
+ end
214
+
215
+ # Gets the IMAP URI.
216
+ def uri
217
+ @uri.to_s
218
+ end
219
+
220
+ # Gets the IMAP mailbox specified in the URI, or +nil+ if none.
221
+ def uri_mailbox
222
+ mb = @uri.path[1..-1]
223
+ mb.nil? || mb.empty? ? nil : CGI.unescape(mb)
224
+ end
225
+
226
+ # Gets the IMAP username.
227
+ def username
228
+ CGI.unescape(@uri.user)
229
+ end
230
+
231
+ private
232
+
233
+ # Resets the connection and mailbox state.
234
+ def reset
235
+ @mutex.synchronize do
236
+ @conn = nil
237
+ @mailboxes.each_value {|mb| mb.reset }
238
+ end
239
+ end
240
+
241
+ def safe_connect
242
+ return if @conn
243
+
244
+ retries = 0
245
+
246
+ begin
247
+ unsafe_connect
248
+
249
+ rescue Errno::ECONNRESET,
250
+ Errno::EPIPE,
251
+ Errno::ETIMEDOUT,
252
+ OpenSSL::SSL::SSLError => e
253
+
254
+ raise unless (retries += 1) <= @options[:max_retries]
255
+
256
+ # Special check to ensure that we don't retry on OpenSSL certificate
257
+ # verification errors.
258
+ raise if e.is_a?(OpenSSL::SSL::SSLError) && e.message =~ /certificate verify failed/
259
+
260
+ info "#{e.class.name}: #{e.message} (will retry)"
261
+
262
+ reset
263
+ sleep 1 * retries
264
+ retry
265
+ end
266
+
267
+ rescue => e
268
+ raise FatalError, "#{e.class.name}: #{e.message} (cannot recover)"
269
+ end
270
+
271
+ def unsafe_connect
272
+ info "connecting..."
273
+
274
+ exception = nil
275
+
276
+ Thread.new do
277
+ begin
278
+ @conn = Net::IMAP.new(host, port, ssl?,
279
+ ssl? && @options[:ssl_verify] ? @options[:ssl_certs] : nil,
280
+ @options[:ssl_verify])
281
+
282
+ info "connected on port #{port}" << (ssl? ? ' using SSL' : '')
283
+
284
+ auth_methods = ['PLAIN']
285
+ tried = []
286
+ capability = @conn.capability
287
+
288
+ ['LOGIN', 'CRAM-MD5'].each do |method|
289
+ auth_methods << method if capability.include?("AUTH=#{method}")
290
+ end
291
+
292
+ begin
293
+ tried << method = auth_methods.pop
294
+
295
+ debug "authenticating using #{method}"
296
+
297
+ if method == 'PLAIN'
298
+ @conn.login(username, password)
299
+ else
300
+ @conn.authenticate(method, username, password)
301
+ end
302
+
303
+ info "authenticated using #{method}"
304
+
305
+ rescue Net::IMAP::BadResponseError, Net::IMAP::NoResponseError => e
306
+ debug "#{method} auth failed: #{e.message}"
307
+ retry unless auth_methods.empty?
308
+
309
+ raise e, "#{e.message} (tried #{tried.join(', ')})"
310
+ end
311
+
312
+ rescue => e
313
+ exception = e
314
+ end
315
+ end.join
316
+
317
+ raise exception if exception
318
+ end
319
+
320
+ def update_mailboxes
321
+ all = safely { @conn.list('', '*') } || []
322
+ subscribed = safely { @conn.lsub('', '*') } || []
323
+
324
+ @mutex.synchronize do
325
+ # Remove cached mailboxes that no longer exist.
326
+ @mailboxes.delete_if {|k, v| !all.any?{|mb| mb.name == k}}
327
+
328
+ # Update cached mailboxes.
329
+ all.each do |mb|
330
+ @mailboxes[mb.name] ||= Mailbox.new(self, mb.name, mb.delim,
331
+ subscribed.any?{|s| s.name == mb.name}, mb.attr)
332
+ end
333
+ end
334
+
335
+ # Remove mailboxes that no longer exist from the database.
336
+ @db_account.mailboxes.each do |db_mailbox|
337
+ db_mailbox.destroy unless @mailboxes.has_key?(db_mailbox.name)
338
+ end
339
+ end
340
+
341
+ end
342
+
343
+ end
@@ -0,0 +1,505 @@
1
+ nodule Larch; class IMAP
2
+
3
+ # Represents an IMAP mailbox.
4
+ class Mailbox
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
9
+
10
+ # Regex to capture a Message-Id header.
11
+ REGEX_MESSAGE_ID = /message-id\s*:\s*(\S+)/i
12
+
13
+ # Minimum time (in seconds) allowed between mailbox scans.
14
+ SCAN_INTERVAL = 60
15
+
16
+ def initialize(imap, name, delim, subscribed, *attr)
17
+ raise ArgumentError, "must provide a Larch::IMAP instance" unless imap.is_a?(Larch::IMAP)
18
+
19
+ @imap = imap
20
+ @name = name
21
+ @name_utf7 = Net::IMAP.encode_utf7(@name)
22
+ @delim = delim
23
+ @subscribed = subscribed
24
+ @attr = attr.flatten
25
+
26
+ @last_scan = nil
27
+ @mutex = Mutex.new
28
+
29
+ # Valid mailbox states are :closed (no mailbox open), :examined (mailbox
30
+ # open and read-only), or :selected (mailbox open and read-write).
31
+ @state = :closed
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
+
50
+ # Create private convenience methods (debug, info, warn, etc.) to make
51
+ # logging easier.
52
+ Logger::LEVELS.each_key do |level|
53
+ Mailbox.class_eval do
54
+ define_method(level) do |msg|
55
+ Larch.log.log(level, "#{@imap.username}@#{@imap.host}: #{@name}: #{msg}")
56
+ end
57
+
58
+ private level
59
+ end
60
+ end
61
+ end
62
+
63
+ # Appends the specified Larch::IMAP::Message to this mailbox if it doesn't
64
+ # already exist. Returns +true+ if the message was appended successfully,
65
+ # +false+ if the message already exists in the mailbox.
66
+ def append(message)
67
+ raise ArgumentError, "must provide a Larch::IMAP::Message object" unless message.is_a?(Larch::IMAP::Message)
68
+ return false if has_guid?(message.guid)
69
+
70
+ @imap.safely do
71
+ unless imap_select(!!@imap.options[:create_mailbox])
72
+ raise Larch::IMAP::Error, "mailbox cannot contain messages: #{@name}"
73
+ end
74
+
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
+ flags = message.flags.dup
80
+ flags.delete(:Recent)
81
+
82
+ @imap.conn.append(@name_utf7, message.rfc822, flags, message.internaldate) unless @imap.options[:dry_run]
83
+ end
84
+
85
+ true
86
+ end
87
+ alias << append
88
+
89
+ # Iterates through messages in this mailbox, yielding the Larch message guid
90
+ # of each to the provided block.
91
+ def each_guid # :yields: guid
92
+ scan
93
+ @db_mailbox.messages.each {|db_message| yield db_message.guid }
94
+ end
95
+
96
+ # Iterates through mailboxes that are first-level children of this mailbox,
97
+ # yielding a Larch::IMAP::Mailbox object for each to the provided block.
98
+ def each_mailbox # :yields: mailbox
99
+ mailboxes.each {|mb| yield mb }
100
+ end
101
+
102
+ # Returns a Larch::IMAP::Message struct representing the message with the
103
+ # specified Larch _guid_, or +nil+ if the specified guid was not found in this
104
+ # mailbox.
105
+ def fetch(guid, peek = false)
106
+ scan
107
+
108
+ unless db_message = @db_mailbox.messages_dataset.filter(:guid => guid).first
109
+ warn "message not found in local db: #{guid}"
110
+ return nil
111
+ end
112
+
113
+ debug "#{peek ? 'peeking at' : 'fetching'} message: #{guid}"
114
+
115
+ imap_uid_fetch([db_message.uid], [(peek ? 'BODY.PEEK[]' : 'BODY[]'), 'FLAGS', 'INTERNALDATE', 'ENVELOPE']) do |fetch_data|
116
+ data = fetch_data.first
117
+ check_response_fields(data, 'BODY[]', 'FLAGS', 'INTERNALDATE', 'ENVELOPE')
118
+
119
+ return Message.new(guid, data.attr['ENVELOPE'], data.attr['BODY[]'],
120
+ data.attr['FLAGS'], Time.parse(data.attr['INTERNALDATE']))
121
+ end
122
+
123
+ warn "message not found on server: #{guid}"
124
+ return nil
125
+ end
126
+ alias [] fetch
127
+
128
+ # Returns +true+ if a message with the specified Larch guid exists in this
129
+ # mailbox, +false+ otherwise.
130
+ def has_guid?(guid)
131
+ scan
132
+ @db_mailbox.messages_dataset.filter(:guid => guid).count > 0
133
+ end
134
+
135
+ # Gets the number of messages in this mailbox.
136
+ def length
137
+ scan
138
+ @db_mailbox.messages_dataset.count
139
+ end
140
+ alias size length
141
+
142
+ # Returns an Array of Larch::IMAP::Mailbox objects representing mailboxes that
143
+ # are first-level children of this mailbox.
144
+ def mailboxes
145
+ return [] if @attr.include?(:Noinferiors)
146
+
147
+ all = @imap.safely{ @imap.conn.list('', "#{@name_utf7}#{@delim}%") } || []
148
+ subscribed = @imap.safely{ @imap.conn.lsub('', "#{@name_utf7}#{@delim}%") } || []
149
+
150
+ all.map{|mb| Mailbox.new(@imap, mb.name, mb.delim,
151
+ subscribed.any?{|s| s.name == mb.name}, mb.attr) }
152
+ end
153
+
154
+ # Same as fetch, but doesn't mark the message as seen.
155
+ def peek(message_id)
156
+ fetch(message_id, true)
157
+ end
158
+
159
+ # Resets the mailbox state.
160
+ def reset
161
+ @mutex.synchronize { @state = :closed }
162
+ end
163
+
164
+ # Fetches message headers from this mailbox.
165
+ def scan
166
+ return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
167
+ first_scan = @last_scan.nil?
168
+ @mutex.synchronize { @last_scan = Time.now }
169
+
170
+ # Compare the mailbox's current status with its last known status.
171
+ begin
172
+ return unless status = imap_status('MESSAGES', 'UIDNEXT', 'UIDVALIDITY')
173
+ rescue Error => e
174
+ return if @imap.options[:create_mailbox]
175
+ raise
176
+ end
177
+
178
+ flag_range = nil
179
+ full_range = nil
180
+
181
+ if @db_mailbox.uidvalidity && @db_mailbox.uidnext &&
182
+ status['UIDVALIDITY'] == @db_mailbox.uidvalidity
183
+
184
+ # The UIDVALIDITY is the same as what we saw last time we scanned this
185
+ # mailbox, which means that all the existing messages in the database are
186
+ # still valid. We only need to request headers for new messages.
187
+ #
188
+ # If this is the first scan of this mailbox during this Larch session,
189
+ # then we'll also update the flags of all messages in the mailbox.
190
+
191
+ flag_range = 1...@db_mailbox.uidnext if first_scan
192
+ full_range = @db_mailbox.uidnext...status['UIDNEXT']
193
+
194
+ else
195
+
196
+ # The UIDVALIDITY has changed or this is the first time we've scanned this
197
+ # mailbox (ever). Either way, all existing messages in the database are no
198
+ # longer valid, so we have to throw them out and re-request everything.
199
+
200
+ @db_mailbox.remove_all_messages
201
+ full_range = 1...status['UIDNEXT']
202
+ end
203
+
204
+ @db_mailbox.update(:uidvalidity => status['UIDVALIDITY'])
205
+
206
+ return unless flag_range || full_range.last - full_range.first > 0
207
+
208
+ # Open the mailbox for read-only access.
209
+ return unless imap_examine
210
+
211
+ if flag_range && flag_range.last - flag_range.first > 0
212
+ info "fetching latest message flags..."
213
+
214
+ expected_uids = {}
215
+ @db_mailbox.messages.each {|db_message| expected_uids[db_message.uid] = true }
216
+
217
+ imap_uid_fetch(flag_range, "(UID FLAGS)", 16384) do |fetch_data|
218
+ Larch.db.transaction do
219
+ fetch_data.each do |data|
220
+ check_response_fields(data, 'UID', 'FLAGS')
221
+ expected_uids.delete(data.attr['UID'])
222
+
223
+ @db_mailbox.messages_dataset.filter(:uid => data.attr['UID']).
224
+ update(:flags => data.attr['FLAGS'].map{|f| f.to_s }.join(','))
225
+ end
226
+ end
227
+ end
228
+
229
+ # Any UIDs that are in the database but weren't in the response have been
230
+ # deleted from the server, so we need to delete them from the database as
231
+ # well.
232
+ unless expected_uids.empty?
233
+ debug "removing #{expected_uids.length} deleted messages from the database..."
234
+
235
+ Larch.db.transaction do
236
+ expected_uids.each do |uid|
237
+ @db_mailbox.messages_dataset.filter(:uid => uid).destroy
238
+ end
239
+ end
240
+ end
241
+
242
+ expected_uids = nil
243
+ fetch_data = nil
244
+ end
245
+
246
+ if full_range && full_range.last - full_range.first > 0
247
+ start = @db_mailbox.messages_dataset.count + 1
248
+ total = status['MESSAGES']
249
+ fetched = 0
250
+ progress = 0
251
+
252
+ show_progress = total - start > FETCH_BLOCK_SIZE * 4
253
+
254
+ info "fetching message headers #{start} through #{total}..."
255
+
256
+ begin
257
+ last_good_uid = nil
258
+
259
+ imap_uid_fetch(full_range, "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] RFC822.SIZE INTERNALDATE FLAGS)") do |fetch_data|
260
+ check_response_fields(fetch_data, 'UID', 'RFC822.SIZE', 'INTERNALDATE', 'FLAGS')
261
+
262
+ Larch.db.transaction do
263
+ fetch_data.each do |data|
264
+ uid = data.attr['UID']
265
+
266
+ Database::Message.create(
267
+ :mailbox_id => @db_mailbox.id,
268
+ :guid => create_guid(data),
269
+ :uid => uid,
270
+ :message_id => parse_message_id(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']),
271
+ :rfc822_size => data.attr['RFC822.SIZE'].to_i,
272
+ :internaldate => Time.parse(data.attr['INTERNALDATE']).to_i,
273
+ :flags => data.attr['FLAGS'].map{|f| f.to_s }.join(',')
274
+ )
275
+
276
+ last_good_uid = uid
277
+ end
278
+
279
+ @db_mailbox.update(:uidnext => last_good_uid + 1)
280
+ end
281
+
282
+ if show_progress
283
+ fetched += fetch_data.length
284
+ last_progress = progress
285
+ progress = ((100 / (total - start).to_f) * fetched).round
286
+
287
+ info "#{progress}% complete" if progress > last_progress
288
+ end
289
+ end
290
+
291
+ rescue => e
292
+ # Set this mailbox's uidnext value to the last known good UID that was
293
+ # stored in the database, plus 1. This will allow Larch to resume where
294
+ # the error occurred on the next attempt rather than having to start over.
295
+ @db_mailbox.update(:uidnext => last_good_uid + 1) if last_good_uid
296
+ raise
297
+ end
298
+ end
299
+
300
+ @db_mailbox.update(:uidnext => status['UIDNEXT'])
301
+ return
302
+ end
303
+
304
+ # Subscribes to this mailbox.
305
+ def subscribe(force = false)
306
+ return false if subscribed? && !force
307
+
308
+ @imap.safely { @imap.conn.subscribe(@name_utf7) } unless @imap.options[:dry_run]
309
+ @mutex.synchronize { @subscribed = true }
310
+ @db_mailbox.update(:subscribed => 1)
311
+
312
+ true
313
+ end
314
+
315
+ # Returns +true+ if this mailbox is subscribed, +false+ otherwise.
316
+ def subscribed?
317
+ @subscribed
318
+ end
319
+
320
+ # Unsubscribes from this mailbox.
321
+ def unsubscribe(force = false)
322
+ return false unless subscribed? || force
323
+
324
+ @imap.safely { @imap.conn.unsubscribe(@name_utf7) } unless @imap.options[:dry_run]
325
+ @mutex.synchronize { @subscribed = false }
326
+ @db_mailbox.update(:subscribed => 0)
327
+
328
+ true
329
+ end
330
+
331
+ private
332
+
333
+ # Checks the specified Net::IMAP::FetchData object and raises a
334
+ # Larch::IMAP::Error unless it contains all the specified _fields_.
335
+ #
336
+ # _data_ can be a single object or an Array of objects; if it's an Array, then
337
+ # only the first object in the Array will be checked.
338
+ def check_response_fields(data, *fields)
339
+ check_data = data.is_a?(Array) ? data.first : data
340
+
341
+ fields.each do |f|
342
+ raise Error, "required data not in IMAP response: #{f}" unless check_data.attr.has_key?(f)
343
+ end
344
+
345
+ true
346
+ end
347
+
348
+ # Creates a globally unique id suitable for identifying a specific message
349
+ # on any mail server (we hope) based on the given IMAP FETCH _data_.
350
+ #
351
+ # If the given message data includes a valid Message-Id header, then that will
352
+ # be used to generate an MD5 hash. Otherwise, the hash will be generated based
353
+ # on the message's RFC822.SIZE and INTERNALDATE.
354
+ def create_guid(data)
355
+ if message_id = parse_message_id(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]'])
356
+ Digest::MD5.hexdigest(message_id)
357
+ else
358
+ check_response_fields(data, 'RFC822.SIZE', 'INTERNALDATE')
359
+
360
+ Digest::MD5.hexdigest(sprintf('%d%d', data.attr['RFC822.SIZE'],
361
+ Time.parse(data.attr['INTERNALDATE']).to_i))
362
+ end
363
+ end
364
+
365
+ # Examines this mailbox. If _force_ is true, the mailbox will be examined even
366
+ # if it is already selected (which isn't necessary unless you want to ensure
367
+ # that it's in a read-only state).
368
+ #
369
+ # Returns +false+ if this mailbox cannot be examined, which may be the case if
370
+ # the \Noselect attribute is set.
371
+ def imap_examine(force = false)
372
+ return false if @attr.include?(:Noselect)
373
+ return true if @state == :examined || (!force && @state == :selected)
374
+
375
+ @imap.safely do
376
+ begin
377
+ @mutex.synchronize { @state = :closed }
378
+
379
+ debug "examining mailbox"
380
+ @imap.conn.examine(@name_utf7)
381
+
382
+ @mutex.synchronize { @state = :examined }
383
+
384
+ rescue Net::IMAP::NoResponseError => e
385
+ raise Error, "unable to examine mailbox: #{e.message}"
386
+ end
387
+ end
388
+
389
+ return true
390
+ end
391
+
392
+ # Selects the mailbox if it is not already selected. If the mailbox does not
393
+ # exist and _create_ is +true+, it will be created. Otherwise, a
394
+ # Larch::IMAP::Error will be raised.
395
+ #
396
+ # Returns +false+ if this mailbox cannot be selected, which may be the case if
397
+ # the \Noselect attribute is set.
398
+ def imap_select(create = false)
399
+ return false if @attr.include?(:Noselect)
400
+ return true if @state == :selected
401
+
402
+ @imap.safely do
403
+ begin
404
+ @mutex.synchronize { @state = :closed }
405
+
406
+ debug "selecting mailbox"
407
+ @imap.conn.select(@name_utf7)
408
+
409
+ @mutex.synchronize { @state = :selected }
410
+
411
+ rescue Net::IMAP::NoResponseError => e
412
+ raise Error, "unable to select mailbox: #{e.message}" unless create
413
+
414
+ info "creating mailbox: #{@name}"
415
+
416
+ begin
417
+ @imap.conn.create(@name_utf7) unless @imap.options[:dry_run]
418
+ retry
419
+ rescue => e
420
+ raise Error, "unable to create mailbox: #{e.message}"
421
+ end
422
+ end
423
+ end
424
+
425
+ return true
426
+ end
427
+
428
+ # Sends an IMAP STATUS command and returns the status of the requested
429
+ # attributes. Supported attributes include:
430
+ #
431
+ # - MESSAGES
432
+ # - RECENT
433
+ # - UIDNEXT
434
+ # - UIDVALIDITY
435
+ # - UNSEEN
436
+ def imap_status(*attr)
437
+ @imap.safely do
438
+ begin
439
+ debug "getting mailbox status"
440
+ @imap.conn.status(@name_utf7, attr)
441
+ rescue Net::IMAP::NoResponseError => e
442
+ raise Error, "unable to get status of mailbox: #{e.message}"
443
+ end
444
+ end
445
+ end
446
+
447
+ # Fetches the specified _fields_ for the specified _set_ of UIDs, which can be
448
+ # a number, Range, or Array of UIDs.
449
+ #
450
+ # If _set_ is a number, an Array containing a single Net::IMAP::FetchData
451
+ # object will be yielded to the given block.
452
+ #
453
+ # If _set_ is a Range or Array of UIDs, Arrays of up to <i>block_size</i>
454
+ # Net::IMAP::FetchData objects will be yielded until all requested messages
455
+ # have been fetched.
456
+ #
457
+ # However, if _set_ is a Range with an end value of -1, a single Array
458
+ # containing all requested messages will be yielded, since it's impossible to
459
+ # divide an infinite range into finite blocks.
460
+ def imap_uid_fetch(set, fields, block_size = FETCH_BLOCK_SIZE, &block) # :yields: fetch_data
461
+ if set.is_a?(Numeric) || (set.is_a?(Range) && set.last < 0)
462
+ data = @imap.safely do
463
+ imap_examine
464
+ @imap.conn.uid_fetch(set, fields)
465
+ end
466
+
467
+ yield data unless data.nil?
468
+ end
469
+
470
+ blocks = []
471
+ pos = 0
472
+
473
+ if set.is_a?(Array)
474
+ while pos < set.length
475
+ blocks += set[pos, block_size]
476
+ pos += block_size
477
+ end
478
+
479
+ elsif set.is_a?(Range)
480
+ pos = set.first - 1
481
+
482
+ while pos < set.last
483
+ blocks << ((pos + 1)..[set.last, pos += block_size].min)
484
+ end
485
+ end
486
+
487
+ blocks.each do |block|
488
+ data = @imap.safely do
489
+ imap_examine
490
+ @imap.conn.uid_fetch(block, fields)
491
+ end
492
+
493
+ yield data unless data.nil?
494
+ end
495
+ end
496
+
497
+ # Parses a Message-Id header out of _str_ and returns it, or +nil+ if _str_
498
+ # doesn't contain a valid Message-Id header.
499
+ def parse_message_id(str)
500
+ return str =~ REGEX_MESSAGE_ID ? $1 : nil
501
+ end
502
+
503
+ end
504
+
505
+ end; end