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.
- data/HISTORY +44 -0
- data/LICENSE +280 -0
- data/README.rdoc +273 -0
- data/bin/larch +123 -0
- data/lib/larch.rb +254 -0
- data/lib/larch/config.rb +105 -0
- data/lib/larch/db/account.rb +12 -0
- data/lib/larch/db/mailbox.rb +12 -0
- data/lib/larch/db/message.rb +6 -0
- data/lib/larch/db/migrate/001_create_schema.rb +42 -0
- data/lib/larch/errors.rb +14 -0
- data/lib/larch/imap.rb +343 -0
- data/lib/larch/imap/mailbox.rb +505 -0
- data/lib/larch/logger.rb +50 -0
- data/lib/larch/version.rb +9 -0
- metadata +106 -0
@@ -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,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
|
data/lib/larch/errors.rb
ADDED
@@ -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
|