larch 1.0.0 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/larch/imap.rb CHANGED
@@ -6,38 +6,29 @@ 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
-
10
- # Recoverable connection errors.
11
- RECOVERABLE_ERRORS = [
12
- Errno::EPIPE,
13
- Errno::ETIMEDOUT,
14
- Net::IMAP::NoResponseError,
15
- OpenSSL::SSL::SSLError
16
- ]
17
-
18
- # Regex to capture the individual fields in an IMAP fetch command.
19
- REGEX_FIELDS = /([0-9A-Z\.]+\[[^\]]+\](?:<[0-9\.]+>)?|[0-9A-Z\.]+)/
20
-
21
- # Regex to capture a Message-Id header.
22
- REGEX_MESSAGE_ID = /message-id\s*:\s*(\S+)/i
9
+ attr_reader :conn, :options
23
10
 
24
11
  # URI format validation regex.
25
12
  REGEX_URI = URI.regexp(['imap', 'imaps'])
26
13
 
27
- # Minimum time (in seconds) allowed between mailbox scans.
28
- SCAN_INTERVAL = 60
29
-
30
14
  # Larch::IMAP::Message represents a transferable IMAP message which can be
31
15
  # passed between Larch::IMAP instances.
32
16
  Message = Struct.new(:id, :envelope, :rfc822, :flags, :internaldate)
33
17
 
34
18
  # Initializes a new Larch::IMAP instance that will connect to the specified
35
- # IMAP URI and authenticate using the specified _username_ and _password_.
19
+ # IMAP URI.
36
20
  #
37
- # The following options may also be specified:
21
+ # In addition to the URI, the following options may also be specified:
38
22
  #
39
23
  # [:create_mailbox]
40
- # If +true+, the specified mailbox will be created if necessary.
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+.
41
32
  #
42
33
  # [:fast_scan]
43
34
  # If +true+, a faster but less accurate method will be used to scan
@@ -51,29 +42,36 @@ class IMAP
51
42
  # After a recoverable error occurs, retry the operation up to this many
52
43
  # times. Default is 3.
53
44
  #
54
- def initialize(uri, username, password, options = {})
45
+ # [:ssl_certs]
46
+ # Path to a trusted certificate bundle to use to verify server SSL
47
+ # certificates. You can download a bundle of certificate authority root
48
+ # certs at http://curl.haxx.se/ca/cacert.pem (it's up to you to verify that
49
+ # this bundle hasn't been tampered with, however; don't trust it blindly).
50
+ #
51
+ # [:ssl_verify]
52
+ # If +true+, server SSL certificates will be verified against the trusted
53
+ # certificate bundle specified in +ssl_certs+. By default, server SSL
54
+ # certificates are not verified.
55
+ #
56
+ def initialize(uri, options = {})
55
57
  raise ArgumentError, "not an IMAP URI: #{uri}" unless uri.is_a?(URI) || uri =~ REGEX_URI
56
- raise ArgumentError, "must provide a username and password" unless username && password
57
58
  raise ArgumentError, "options must be a Hash" unless options.is_a?(Hash)
58
59
 
59
- @uri = uri.is_a?(URI) ? uri : URI(uri)
60
- @username = username
61
- @password = password
62
- @options = {:max_retries => 3}.merge(options)
60
+ @options = {:max_retries => 3, :ssl_verify => false}.merge(options)
61
+ @uri = uri.is_a?(URI) ? uri : URI(uri)
63
62
 
64
- @ids = {}
65
- @imap = nil
66
- @last_id = 0
67
- @last_scan = nil
68
- @message_ids = nil
69
- @mutex = Mutex.new
63
+ raise ArgumentError, "must provide a username and password" unless @uri.user && @uri.password
64
+
65
+ @conn = nil
66
+ @mailboxes = {}
67
+ @mutex = Mutex.new
70
68
 
71
69
  # Create private convenience methods (debug, info, warn, etc.) to make
72
70
  # logging easier.
73
71
  Logger::LEVELS.each_key do |level|
74
72
  IMAP.class_eval do
75
73
  define_method(level) do |msg|
76
- Larch.log.log(level, "#{@username}@#{host}: #{msg}")
74
+ Larch.log.log(level, "#{username}@#{host}: #{msg}")
77
75
  end
78
76
 
79
77
  private level
@@ -81,120 +79,80 @@ class IMAP
81
79
  end
82
80
  end
83
81
 
84
- # Appends the specified Larch::IMAP::Message to this mailbox if it doesn't
85
- # already exist. Returns +true+ if the message was appended successfully,
86
- # +false+ if the message already exists in the mailbox.
87
- def append(message)
88
- raise ArgumentError, "must provide a Larch::IMAP::Message object" unless message.is_a?(Message)
89
- return false if has_message?(message)
90
-
91
- safely do
92
- @mutex.synchronize do
93
- begin
94
- @imap.select(mailbox)
95
- rescue Net::IMAP::NoResponseError => e
96
- if @options[:create_mailbox]
97
- info "creating mailbox: #{mailbox}"
98
- @imap.create(mailbox)
99
- retry
100
- end
101
-
102
- raise
103
- end
104
- end
105
-
106
- debug "appending message: #{message.id}"
107
- @imap.append(mailbox, message.rfc822, message.flags, message.internaldate)
108
- end
109
-
110
- true
111
- end
112
- alias << append
113
-
114
82
  # Connects to the IMAP server and logs in if a connection hasn't already been
115
83
  # established.
116
84
  def connect
117
- return if @imap
85
+ return if @conn
118
86
  safely {} # connect, but do nothing else
119
87
  end
120
88
 
89
+ # Gets the server's mailbox hierarchy delimiter.
90
+ def delim
91
+ @delim ||= safely { @conn.list('', '')[0].delim }
92
+ end
93
+
121
94
  # Closes the IMAP connection if one is currently open.
122
95
  def disconnect
123
- return unless @imap
96
+ return unless @conn
124
97
 
125
- @imap.disconnect
126
- @imap = nil
98
+ begin
99
+ @conn.disconnect
100
+ rescue Errno::ENOTCONN => e
101
+ debug "#{e.class.name}: #{e.message}"
102
+ end
103
+
104
+ reset
127
105
 
128
106
  info "disconnected"
129
107
  end
130
- synchronized :disconnect
131
-
132
- # Iterates through Larch message ids in this mailbox, yielding each one to the
133
- # provided block.
134
- def each
135
- ids = @mutex.synchronize do
136
- unsync_scan_mailbox
137
- @ids
138
- end
139
108
 
140
- ids.each_key {|id| yield id }
109
+ # Iterates through all mailboxes in the account, yielding each one as a
110
+ # Larch::IMAP::Mailbox instance to the given block.
111
+ def each_mailbox
112
+ update_mailboxes
113
+ @mailboxes.each_value {|mailbox| yield mailbox }
141
114
  end
142
115
 
143
- # Gets a Net::IMAP::Envelope for the specified message id.
144
- def envelope(message_id)
145
- scan_mailbox
146
- uid = @ids[message_id]
147
-
148
- raise NotFoundError, "message not found: #{message_id}" if uid.nil?
149
-
150
- debug "fetching envelope: #{message_id}"
151
- imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
116
+ # Gets the IMAP hostname.
117
+ def host
118
+ @uri.host
152
119
  end
153
120
 
154
- # Fetches a Larch::IMAP::Message struct representing the message with the
155
- # specified Larch message id.
156
- def fetch(message_id, peek = false)
157
- scan_mailbox
158
- uid = @ids[message_id]
159
-
160
- raise NotFoundError, "message not found: #{message_id}" if uid.nil?
121
+ # Gets a Larch::IMAP::Mailbox instance representing the specified mailbox. If
122
+ # the mailbox doesn't exist and the <tt>:create_mailbox</tt> option is
123
+ # +false+, or if <tt>:create_mailbox</tt> is +true+ and mailbox creation
124
+ # fails, a Larch::IMAP::MailboxNotFoundError will be raised.
125
+ def mailbox(name, delim = '/')
126
+ retries = 0
161
127
 
162
- debug "#{peek ? 'peeking at' : 'fetching'} message: #{message_id}"
163
- data = imap_uid_fetch([uid], [(peek ? 'BODY.PEEK[]' : 'BODY[]'), 'FLAGS', 'INTERNALDATE', 'ENVELOPE']).first
128
+ name = name.gsub(delim, self.delim)
164
129
 
165
- Message.new(message_id, data.attr['ENVELOPE'], data.attr['BODY[]'],
166
- data.attr['FLAGS'], Time.parse(data.attr['INTERNALDATE']))
167
- end
168
- alias [] fetch
130
+ begin
131
+ @mailboxes.fetch(name) do
132
+ update_mailboxes
133
+ return @mailboxes[name] if @mailboxes.has_key?(name)
134
+ raise MailboxNotFoundError, "mailbox not found: #{name}"
135
+ end
169
136
 
170
- # Returns +true+ if a message with the specified Larch <em>message_id</em>
171
- # exists in this mailbox, +false+ otherwise.
172
- def has_message?(message_id)
173
- scan_mailbox
174
- @ids.has_key?(message_id)
175
- end
137
+ rescue MailboxNotFoundError => e
138
+ raise unless @options[:create_mailbox] && retries == 0
176
139
 
177
- # Gets the IMAP hostname.
178
- def host
179
- @uri.host
180
- end
140
+ info "creating mailbox: #{name}"
141
+ safely { @conn.create(name) } unless @options[:dry_run]
181
142
 
182
- # Gets the number of messages in this mailbox.
183
- def length
184
- scan_mailbox
185
- @ids.length
143
+ retries += 1
144
+ retry
145
+ end
186
146
  end
187
- alias size length
188
147
 
189
- # Gets the IMAP mailbox.
190
- def mailbox
191
- mb = @uri.path[1..-1]
192
- mb.nil? || mb.empty? ? 'INBOX' : CGI.unescape(mb)
148
+ # Sends an IMAP NOOP command.
149
+ def noop
150
+ safely { @conn.noop }
193
151
  end
194
152
 
195
- # Same as fetch, but doesn't mark the message as seen.
196
- def peek(message_id)
197
- fetch(message_id, true)
153
+ # Gets the IMAP password.
154
+ def password
155
+ CGI.unescape(@uri.password)
198
156
  end
199
157
 
200
158
  # Gets the IMAP port number.
@@ -202,53 +160,53 @@ class IMAP
202
160
  @uri.port || (ssl? ? 993 : 143)
203
161
  end
204
162
 
205
- # Fetches message headers from the current mailbox.
206
- def scan_mailbox
207
- return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
163
+ # Connect if necessary, execute the given block, retry if a recoverable error
164
+ # occurs, die if an unrecoverable error occurs.
165
+ def safely
166
+ safe_connect
208
167
 
209
- last_id = safely do
210
- begin
211
- @imap.examine(mailbox)
212
- rescue Net::IMAP::NoResponseError => e
213
- return if @options[:create_mailbox]
214
- raise FatalError, "unable to open mailbox: #{e.message}"
215
- end
168
+ retries = 0
216
169
 
217
- @imap.responses['EXISTS'].last
218
- end
170
+ begin
171
+ yield
219
172
 
220
- @last_scan = Time.now
221
- return if last_id == @last_id
173
+ rescue Errno::ECONNRESET,
174
+ Errno::ENOTCONN,
175
+ Errno::EPIPE,
176
+ Errno::ETIMEDOUT,
177
+ Net::IMAP::ByeResponseError,
178
+ OpenSSL::SSL::SSLError => e
222
179
 
223
- range = (@last_id + 1)..last_id
224
- @last_id = last_id
180
+ raise unless (retries += 1) <= @options[:max_retries]
225
181
 
226
- info "fetching message headers #{range}" <<
227
- (@options[:fast_scan] ? ' (fast scan)' : '')
182
+ info "#{e.class.name}: #{e.message} (reconnecting)"
228
183
 
229
- fields = if @options[:fast_scan]
230
- ['UID', 'RFC822.SIZE', 'INTERNALDATE']
231
- else
232
- "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] RFC822.SIZE INTERNALDATE)"
233
- end
184
+ reset
185
+ sleep 1 * retries
186
+ safe_connect
187
+ retry
234
188
 
235
- imap_fetch(range, fields).each do |data|
236
- id = create_id(data)
189
+ rescue Net::IMAP::BadResponseError,
190
+ Net::IMAP::NoResponseError,
191
+ Net::IMAP::ResponseParseError => e
237
192
 
238
- unless uid = data.attr['UID']
239
- error "UID not in IMAP response for message: #{id}"
240
- next
241
- end
193
+ raise unless (retries += 1) <= @options[:max_retries]
242
194
 
243
- if @ids.has_key?(id) && Larch.log.level == :debug
244
- envelope = imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
245
- debug "duplicate message? #{id} (Subject: #{envelope.subject})"
246
- end
195
+ info "#{e.class.name}: #{e.message} (will retry)"
247
196
 
248
- @ids[id] = uid
197
+ sleep 1 * retries
198
+ retry
249
199
  end
200
+
201
+ rescue Larch::Error => e
202
+ raise
203
+
204
+ rescue Net::IMAP::Error => e
205
+ raise Error, "#{e.class.name}: #{e.message} (giving up)"
206
+
207
+ rescue => e
208
+ raise FatalError, "#{e.class.name}: #{e.message} (cannot recover)"
250
209
  end
251
- synchronized :scan_mailbox
252
210
 
253
211
  # Gets the SSL status.
254
212
  def ssl?
@@ -260,111 +218,55 @@ class IMAP
260
218
  @uri.to_s
261
219
  end
262
220
 
263
- private
264
-
265
- # Creates an id suitable for uniquely identifying a specific message across
266
- # servers (we hope).
267
- #
268
- # If the given message data includes a valid Message-Id header, then that will
269
- # be used to generate an MD5 hash. Otherwise, the hash will be generated based
270
- # on the message's RFC822.SIZE and INTERNALDATE.
271
- def create_id(data)
272
- ['RFC822.SIZE', 'INTERNALDATE'].each do |a|
273
- raise FatalError, "requested data not in IMAP response: #{a}" unless data.attr[a]
274
- end
275
-
276
- if data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]'] =~ REGEX_MESSAGE_ID
277
- Digest::MD5.hexdigest($1)
278
- else
279
- Digest::MD5.hexdigest(sprintf('%d%d', data.attr['RFC822.SIZE'],
280
- Time.parse(data.attr['INTERNALDATE']).to_i))
281
- end
221
+ # Gets the IMAP mailbox specified in the URI, or +nil+ if none.
222
+ def uri_mailbox
223
+ mb = @uri.path[1..-1]
224
+ mb.nil? || mb.empty? ? nil : CGI.unescape(mb)
282
225
  end
283
226
 
284
- # Fetches the specified _fields_ for the specified message sequence id(s) from
285
- # the IMAP server.
286
- def imap_fetch(ids, fields)
287
- data = safely { @imap.fetch(ids, fields) }
288
-
289
- # If fields isn't an array, make it one.
290
- fields = REGEX_FIELDS.match(fields).captures unless fields.is_a?(Array)
291
-
292
- # Translate BODY.PEEK to BODY in fields, since that's how it'll come back in
293
- # the response.
294
- fields.map! {|f| f.sub(/^BODY\.PEEK\[/, 'BODY[') }
295
-
296
- good_results = ids.respond_to?(:member?) ?
297
- data.find_all {|i| ids.member?(i.seqno) && fields.all? {|f| i.attr.member?(f) }} :
298
- data.find_all {|i| ids == i.seqno && fields.all? {|f| i.attr.member?(f) }}
299
-
300
- if good_results.empty?
301
- raise FatalError, "0 out of #{data.length} items in IMAP response for message(s) #{ids} contained all requested fields: #{fields.join(', ')}"
302
- elsif good_results.length < data.length
303
- error "IMAP server sent #{good_results.length} results in response to a request for #{data.length} messages"
304
- end
305
-
306
- good_results
227
+ # Gets the IMAP username.
228
+ def username
229
+ CGI.unescape(@uri.user)
307
230
  end
308
231
 
309
- # Fetches the specified _fields_ for the specified UID(s) from the IMAP
310
- # server.
311
- def imap_uid_fetch(uids, fields)
312
- data = safely { @imap.uid_fetch(uids, fields) }
313
-
314
- # If fields isn't an array, make it one.
315
- fields = REGEX_FIELDS.match(fields).captures unless fields.is_a?(Array)
316
-
317
- # Translate BODY.PEEK to BODY in fields, since that's how it'll come back in
318
- # the response.
319
- fields.map! {|f| f.sub(/^BODY\.PEEK\[/, 'BODY[') }
320
-
321
- good_results = data.find_all do |i|
322
- i.attr.member?('UID') && uids.member?(i.attr['UID']) &&
323
- fields.all? {|f| i.attr.member?(f) }
324
- end
232
+ private
325
233
 
326
- if good_results.empty?
327
- raise FatalError, "0 out of #{data.length} items in IMAP response for message UID(s) #{uids.join(', ')} contained all requested fields: #{fields.join(', ')}"
328
- elsif good_results.length < data.length
329
- error "IMAP server sent #{good_results.length} results in response to a request for #{data.length} messages"
234
+ # Resets the connection and mailbox state.
235
+ def reset
236
+ @mutex.synchronize do
237
+ @conn = nil
238
+ @mailboxes.each_value {|mb| mb.reset }
330
239
  end
331
-
332
- good_results
333
240
  end
334
241
 
335
- # Connect if necessary, execute the given block, retry up to 3 times if a
336
- # recoverable error occurs, die if an unrecoverable error occurs.
337
- def safely
242
+ def safe_connect
243
+ return if @conn
244
+
338
245
  retries = 0
339
246
 
340
247
  begin
341
- unsafe_connect unless @imap
342
- rescue *RECOVERABLE_ERRORS => e
343
- info "#{e.class.name}: #{e.message} (will retry)"
344
- raise unless (retries += 1) <= @options[:max_retries]
248
+ unsafe_connect
345
249
 
346
- @imap = nil
347
- sleep 1 * retries
348
- retry
349
- end
250
+ rescue Errno::ECONNRESET,
251
+ Errno::EPIPE,
252
+ Errno::ETIMEDOUT,
253
+ OpenSSL::SSL::SSLError => e
350
254
 
351
- retries = 0
255
+ raise unless (retries += 1) <= @options[:max_retries]
256
+
257
+ # Special check to ensure that we don't retry on OpenSSL certificate
258
+ # verification errors.
259
+ raise if e.is_a?(OpenSSL::SSL::SSLError) && e.message =~ /certificate verify failed/
352
260
 
353
- begin
354
- yield
355
- rescue *RECOVERABLE_ERRORS => e
356
261
  info "#{e.class.name}: #{e.message} (will retry)"
357
- raise unless (retries += 1) <= @options[:max_retries]
358
262
 
263
+ reset
359
264
  sleep 1 * retries
360
265
  retry
361
266
  end
362
267
 
363
- rescue Net::IMAP::NoResponseError => e
364
- raise Error, "#{e.class.name}: #{e.message} (giving up)"
365
-
366
- rescue IOError, Net::IMAP::Error, OpenSSL::SSL::SSLError, SocketError, SystemCallError => e
367
- raise FatalError, "#{e.class.name}: #{e.message} (giving up)"
268
+ rescue => e
269
+ raise FatalError, "#{e.class.name}: #{e.message} (cannot recover)"
368
270
  end
369
271
 
370
272
  def unsafe_connect
@@ -374,15 +276,18 @@ class IMAP
374
276
 
375
277
  Thread.new do
376
278
  begin
377
- @imap = Net::IMAP.new(host, port, ssl?)
279
+ @conn = Net::IMAP.new(host, port, ssl?,
280
+ ssl? && @options[:ssl_verify] ? @options[:ssl_certs] : nil,
281
+ @options[:ssl_verify])
378
282
 
379
283
  info "connected on port #{port}" << (ssl? ? ' using SSL' : '')
380
284
 
381
285
  auth_methods = ['PLAIN']
382
286
  tried = []
287
+ capability = @conn.capability
383
288
 
384
289
  ['LOGIN', 'CRAM-MD5'].each do |method|
385
- auth_methods << method if @imap.capability.include?("AUTH=#{method}")
290
+ auth_methods << method if capability.include?("AUTH=#{method}")
386
291
  end
387
292
 
388
293
  begin
@@ -391,9 +296,9 @@ class IMAP
391
296
  debug "authenticating using #{method}"
392
297
 
393
298
  if method == 'PLAIN'
394
- @imap.login(@username, @password)
299
+ @conn.login(username, password)
395
300
  else
396
- @imap.authenticate(method, @username, @password)
301
+ @conn.authenticate(method, username, password)
397
302
  end
398
303
 
399
304
  info "authenticated using #{method}"
@@ -407,12 +312,28 @@ class IMAP
407
312
 
408
313
  rescue => e
409
314
  exception = e
410
- error e.message
411
315
  end
412
316
  end.join
413
317
 
414
318
  raise exception if exception
415
319
  end
320
+
321
+ def update_mailboxes
322
+ all = safely { @conn.list('', '*') } || []
323
+ subscribed = safely { @conn.lsub('', '*') } || []
324
+
325
+ @mutex.synchronize do
326
+ # Remove cached mailboxes that no longer exist.
327
+ @mailboxes.delete_if {|k, v| !all.any?{|mb| mb.name == k}}
328
+
329
+ # Update cached mailboxes.
330
+ all.each do |mb|
331
+ @mailboxes[mb.name] ||= Mailbox.new(self, mb.name, mb.delim,
332
+ subscribed.any?{|s| s.name == mb.name}, mb.attr)
333
+ end
334
+ end
335
+ end
336
+
416
337
  end
417
338
 
418
339
  end
data/lib/larch/logger.rb CHANGED
@@ -4,11 +4,12 @@ class Logger
4
4
  attr_reader :level, :output
5
5
 
6
6
  LEVELS = {
7
- :fatal => 0,
8
- :error => 1,
9
- :warn => 2,
10
- :info => 3,
11
- :debug => 4
7
+ :fatal => 0,
8
+ :error => 1,
9
+ :warn => 2,
10
+ :info => 3,
11
+ :debug => 4,
12
+ :insane => 5
12
13
  }
13
14
 
14
15
  def initialize(level = :info, output = $stdout)
data/lib/larch/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Larch
2
2
  APP_NAME = 'Larch'
3
- APP_VERSION = '1.0.0'
3
+ APP_VERSION = '1.0.1'
4
4
  APP_AUTHOR = 'Ryan Grove'
5
5
  APP_EMAIL = 'ryan@wonko.com'
6
6
  APP_URL = 'http://github.com/rgrove/larch/'