larch 1.0.0 → 1.0.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 ADDED
@@ -0,0 +1,23 @@
1
+ Larch History
2
+ ================================================================================
3
+
4
+ Version 1.0.1 (2009-05-10)
5
+ * Ruby 1.9.1 support.
6
+ * Much more robust handling of unexpected server disconnects and dropped
7
+ connections.
8
+ * Added --all option to copy all folders recursively.
9
+ * Added --all-subscribed option to copy all subscribed folders recursively.
10
+ * Added --dry-run option to simulate changes without actually making them.
11
+ * Added --exclude and --exclude-file options to specify folders that should
12
+ not be copied.
13
+ * Added --ssl-certs option to specify a bundle of trusted SSL certificates.
14
+ * Added --ssl-verify option to verify server SSL certificates.
15
+ * Added a new "insane" logging level, which will output all IMAP commands and
16
+ responses to STDERR.
17
+ * Fixed excessive post-scan processing times for very large mailboxes.
18
+ * Fixed potential scan problems with very large mailboxes on certain servers.
19
+ * POSIX signals are no longer trapped on platforms that aren't likely to
20
+ support them.
21
+
22
+ Version 1.0.0 (2009-03-17)
23
+ * First release.
data/README.rdoc ADDED
@@ -0,0 +1,185 @@
1
+ = Larch
2
+
3
+ Larch is a tool to copy messages from one IMAP server to another quickly and
4
+ safely. It's smart enough not to copy messages that already exist on the
5
+ destination and robust enough to deal with ornery or misbehaving servers.
6
+
7
+ Larch is particularly well-suited for copying email to, from, or between Gmail
8
+ accounts.
9
+
10
+ *Author*:: Ryan Grove (mailto:ryan@wonko.com)
11
+ *Version*:: 1.0.1 (2009-05-10)
12
+ *Copyright*:: Copyright (c) 2009 Ryan Grove. All rights reserved.
13
+ *License*:: GPL 2.0 (http://opensource.org/licenses/gpl-2.0.php)
14
+ *Website*:: http://github.com/rgrove/larch
15
+
16
+ == Installation
17
+
18
+ Install Larch via RubyGems:
19
+
20
+ gem install larch
21
+
22
+ == Usage
23
+
24
+ larch --from <uri> --to <uri> [options]
25
+
26
+ Required:
27
+ --from, -f <s>: URI of the source IMAP server.
28
+ --to, -t <s>: URI of the destination IMAP server.
29
+
30
+ Copy Options:
31
+ --all: Copy all folders recursively
32
+ --all-subscribed: Copy all subscribed folders recursively
33
+ --exclude <s+>: List of mailbox names/patterns that shouldn't be
34
+ copied
35
+ --exclude-file <s>: Filename containing mailbox names/patterns that
36
+ shouldn't be copied
37
+ --from-folder <s>: Source folder to copy from (default: INBOX)
38
+ --from-pass <s>: Source server password (default: prompt)
39
+ --from-user <s>: Source server username (default: prompt)
40
+ --to-folder <s>: Destination folder to copy to (default: INBOX)
41
+ --to-pass <s>: Destination server password (default: prompt)
42
+ --to-user <s>: Destination server username (default: prompt)
43
+
44
+ General Options:
45
+ --dry-run, -n: Don't actually make any changes.
46
+ --fast-scan: Use a faster (but less accurate) method to scan
47
+ mailboxes. This may result in messages being
48
+ re-copied.
49
+ --max-retries <i>: Maximum number of times to retry after a recoverable
50
+ error (default: 3)
51
+ --no-create-folder: Don't create destination folders that don't already
52
+ exist
53
+ --ssl-certs <s>: Path to a trusted certificate bundle to use to
54
+ verify server SSL certificates
55
+ --ssl-verify: Verify server SSL certificates
56
+ --verbosity, -V <s>: Output verbosity: debug, info, warn, error, or fatal
57
+ (default: info)
58
+ --version, -v: Print version and exit
59
+ --help, -h: Show this message
60
+
61
+ == Examples
62
+
63
+ Larch is run from the command line. At a minimum, you must specify a source
64
+ server and a destination server in the form of IMAP URIs. You may also specify
65
+ one or more options:
66
+
67
+ larch --from <uri> --to <uri> [options]
68
+
69
+ For an overview of all available command-line options, run:
70
+
71
+ larch --help
72
+
73
+ Specify a source server and a destination server and Larch will prompt you for
74
+ the necessary usernames and passwords, then sync the contents of the source's
75
+ +INBOX+ folder to the destination:
76
+
77
+ larch --from imap://mail.example.com --to imap://imap.gmail.com
78
+
79
+ To <b>connect using SSL</b>, specify a URI beginning with <tt>imaps://</tt>:
80
+
81
+ larch --from imaps://mail.example.com --to imaps://imap.gmail.com
82
+
83
+ If you'd like to <b>sync a specific folder</b> other than +INBOX+, specify the
84
+ source and destination folders using <tt>--from-folder</tt> and
85
+ <tt>--to-folder</tt>:
86
+
87
+ larch --from imaps://mail.example.com --to imaps://imap.gmail.com \
88
+ --from-folder "Sent Mail" --to-folder "Sent Mail"
89
+
90
+ To <b>sync all folders</b>, use the <tt>--all</tt> option (or
91
+ <tt>--all-subscribed</tt> if you only want to <b>sync subscribed folders</b>):
92
+
93
+ larch --from imaps://mail.example.com --to imaps://imap.gmail.com --all
94
+
95
+ By default Larch will create folders on the destination server if they don't
96
+ already exist. To prevent this, add the <tt>--no-create-folder</tt> option:
97
+
98
+ larch --from imaps://mail.example.com --to imaps://imap.gmail.com --all \
99
+ --no-create-folder
100
+
101
+ You can <b>prevent Larch from syncing one or more folders</b> by using the
102
+ <tt>--exclude</tt> option:
103
+
104
+ larch --from imaps://mail.example.com --to imaps://imap.gmail.com --all \
105
+ --exclude Spam Trash Drafts "[Gmail]/*"
106
+
107
+ If your exclusion list is long or complex, create a text file with one exclusion
108
+ pattern per line and tell Larch to load it with the <tt>--exclude-file</tt>
109
+ option:
110
+
111
+ larch --from imaps://mail.example.com --to imaps://imap.gmail.com --all \
112
+ --exclude-file exclude.txt
113
+
114
+ The wildcard characters <tt>*</tt> and <tt>?</tt> are supported in exclusion
115
+ lists. You can also use a regular expression by enclosing a pattern in
116
+ forward slashes, so the previous example could be achieved with the
117
+ pattern <tt>/(Spam|Trash|Drafts|\[Gmail\]\/.*)/</tt>
118
+
119
+ == Server Compatibility
120
+
121
+ Larch should work well with any server that properly supports
122
+ IMAP4rev1[http://tools.ietf.org/html/rfc3501], and does its best to get along
123
+ with servers that have buggy, unreliable, or incomplete IMAP implementations.
124
+
125
+ Larch has been tested on and is known to work well with the following IMAP
126
+ servers:
127
+
128
+ * Dovecot
129
+ * Gmail
130
+ * Microsoft Exchange 2003
131
+
132
+ == Known Issues
133
+
134
+ * Larch uses Ruby's Net::IMAP standard library for all IMAP operations. While
135
+ Net::IMAP is generally a very solid library, it contains a bug that can
136
+ cause a deadlock to occur if a connection drops unexpectedly (either due to
137
+ network issues or because the server closed the connection without warning)
138
+ when the server has already begun sending a response and Net::IMAP is
139
+ waiting to receive more data.
140
+
141
+ If this happens, Net::IMAP will continue waiting forever without passing
142
+ control back to Larch, and you will need to manually kill and restart Larch.
143
+
144
+ * The Ruby package on Debian, Ubuntu, and some other Debian-based Linux
145
+ distributions doesn't include the OpenSSL standard library. If you see an
146
+ error like <tt>uninitialized constant Larch::IMAP::OpenSSL (NameError)</tt>
147
+ when running Larch, you may need to install the <tt>libopenssl-ruby</tt>
148
+ package. Please feel free to complain to the maintainer of your distribution's
149
+ Ruby packages.
150
+
151
+ == Support
152
+
153
+ The Larch mailing list is the best place for questions, comments, and discussion
154
+ about Larch. You can join the list or view the archives at
155
+ http://groups.google.com/group/larch
156
+
157
+ == Credit
158
+
159
+ The Larch::IMAP class borrows heavily from Sup[http://sup.rubyforge.org] by
160
+ William Morgan, the source code of which should be required reading if you're
161
+ doing anything with IMAP in Ruby.
162
+
163
+ Larch uses the excellent Trollop[http://trollop.rubyforge.org] command-line
164
+ option parser (also by William Morgan) and the
165
+ HighLine[http://highline.rubyforge.org] command-line IO library (by James Edward
166
+ Gray II).
167
+
168
+ == License
169
+
170
+ Copyright (c) 2009 Ryan Grove <ryan@wonko.com>
171
+
172
+ Licensed under the GNU General Public License version 2.0.
173
+
174
+ This program is free software; you can redistribute it and/or modify it under
175
+ the terms of version 2.0 of the GNU General Public License as published by the
176
+ Free Software Foundation.
177
+
178
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY
179
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
180
+ PARTICULAR PURPOSE. See the GNU General Public License for more details.
181
+
182
+ You should have received a copy of the GNU General Public License along with
183
+ this program; if not, visit http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
184
+ or write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
185
+ Boston, MA 02111-1307 USA.
data/bin/larch CHANGED
@@ -22,18 +22,25 @@ 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 "\nOptions:"
25
+ text "\nCopy Options:"
26
+ opt :all, "Copy all folders recursively", :short => :none
27
+ opt :all_subscribed, "Copy all subscribed folders recursively", :short => :none
28
+ opt :exclude, "List of mailbox names/patterns that shouldn't be copied", :short => :none, :type => :strings, :multi => true
29
+ opt :exclude_file, "Filename containing mailbox names/patterns that shouldn't be copied", :short => :none, :type => :string
30
+ opt :from_folder, "Source folder to copy from", :short => :none, :default => 'INBOX'
31
+ opt :from_pass, "Source server password (default: prompt)", :short => :none, :type => :string
32
+ opt :from_user, "Source server username (default: prompt)", :short => :none, :type => :string
33
+ opt :to_folder, "Destination folder to copy to", :short => :none, :default => 'INBOX'
34
+ opt :to_pass, "Destination server password (default: prompt)", :short => :none, :type => :string
35
+ opt :to_user, "Destination server username (default: prompt)", :short => :none, :type => :string
26
36
 
27
- # opt :dry_run, "Don't actually do anything.", :short => '-n'
37
+ text "\nGeneral Options:"
38
+ opt :dry_run, "Don't actually make any changes.", :short => '-n'
28
39
  opt :fast_scan, "Use a faster (but less accurate) method to scan mailboxes. This may result in messages being re-copied.", :short => :none
29
- opt :from_folder, "Source folder to copy from", :short => :none, :default => 'INBOX'
30
- opt :from_pass, "Source server password (Default: prompt)", :short => :none, :type => :string
31
- opt :from_user, "Source server username (Default: prompt)", :short => :none, :type => :string
32
40
  opt :max_retries, "Maximum number of times to retry after a recoverable error", :short => :none, :default => 3
33
41
  opt :no_create_folder, "Don't create destination folders that don't already exist", :short => :none
34
- opt :to_folder, "Destination folder to copy to", :short => :none, :default => 'INBOX'
35
- opt :to_pass, "Destination server password (Default: prompt)", :short => :none, :type => :string
36
- opt :to_user, "Destination server username (Default: prompt)", :short => :none, :type => :string
42
+ opt :ssl_certs, "Path to a trusted certificate bundle to use to verify server SSL certificates", :short => :none, :type => :string
43
+ opt :ssl_verify, "Verify server SSL certificates", :short => :none
37
44
  opt :verbosity, "Output verbosity: debug, info, warn, error, or fatal", :short => '-V', :default => 'info'
38
45
  end
39
46
 
@@ -48,44 +55,94 @@ EOS
48
55
  Trollop.die :verbosity, "must be one of: #{Logger::LEVELS.keys.join(', ')}"
49
56
  end
50
57
 
51
- # Create URIs.
52
- options[:from] = URI(options[:from])
53
- options[:to] = URI(options[:to])
54
- options[:from].path = '/' + CGI.escape(options[:from_folder].gsub(/^\//, ''))
55
- options[:to].path = '/' + CGI.escape(options[:to_folder].gsub(/^\//, ''))
56
-
57
- # Prompt for usernames and passwords if necessary.
58
- unless options[:from_user]
59
- options[:from_user] = ask("Source username (#{options[:from].host}): ")
58
+ if options[:exclude_file_given]
59
+ filename = options[:exclude_file]
60
+
61
+ Trollop.die :exclude_file, ": file not found: #{filename}" unless File.file?(filename)
62
+ Trollop.die :exclude_file, ": file cannot be read: #{filename}" unless File.readable?(filename)
60
63
  end
61
64
 
62
- unless options[:from_pass]
63
- options[:from_pass] = ask("Source password (#{options[:from].host}): ") {|q| q.echo = false }
65
+ # Prevent conflicting options from being used.
66
+ if options[:all_given]
67
+ [:all_subscribed, :from_folder, :to_folder].each do |o|
68
+ Trollop.die :all, "may not be used with --#{o.to_s.gsub('_', '-')}" if options["#{o}_given".to_sym]
69
+ end
64
70
  end
65
71
 
66
- unless options[:to_user]
67
- options[:to_user] = ask("Destination username (#{options[:to].host}): ")
72
+ if options[:all_subscribed_given]
73
+ [:all, :from_folder, :to_folder].each do |o|
74
+ Trollop.die :all_subscribed, "may not be used with --#{o.to_s.gsub('_', '-')}" if options["#{o}_given".to_sym]
75
+ end
68
76
  end
69
77
 
70
- unless options[:to_pass]
71
- options[:to_pass] = ask("Destination password (#{options[:to].host}): ") {|q| q.echo = false }
78
+ # Create URIs.
79
+ uri_from = URI(options[:from])
80
+ uri_to = URI(options[:to])
81
+
82
+ # --all and --all-subscribed options override folders specified in URIs
83
+ if options[:all_given] || options[:all_subscribed_given]
84
+ uri_from.path = ''
85
+ uri_to.path = ''
72
86
  end
73
87
 
88
+ # --from-folder and --to-folder options override folders specified in URIs
89
+ if options[:from_folder_given] || options[:to_folder_given]
90
+ uri_from.path = '/' + CGI.escape(options[:from_folder].gsub(/^\//, ''))
91
+ uri_to.path = '/' + CGI.escape(options[:to_folder].gsub(/^\//, ''))
92
+ end
93
+
94
+ # Usernames and passwords specified as arguments override those in the URIs
95
+ uri_from.user = CGI.escape(options[:from_user]) if options[:from_user]
96
+ uri_from.password = CGI.escape(options[:from_pass]) if options[:from_pass]
97
+ uri_to.user = CGI.escape(options[:to_user]) if options[:to_user]
98
+ uri_to.password = CGI.escape(options[:to_pass]) if options[:to_pass]
99
+
100
+ # If usernames/passwords aren't specified in either URIs or args, then prompt.
101
+ uri_from.user ||= CGI.escape(ask("Source username (#{uri_from.host}): "))
102
+ uri_from.password ||= CGI.escape(ask("Source password (#{uri_from.host}): ") {|q| q.echo = false })
103
+ uri_to.user ||= CGI.escape(ask("Destination username (#{uri_to.host}): "))
104
+ uri_to.password ||= CGI.escape(ask("Destination password (#{uri_to.host}): ") {|q| q.echo = false })
105
+
74
106
  # Go go go!
75
- init(options[:verbosity])
107
+ init(
108
+ options[:verbosity],
109
+ options[:exclude] ? options[:exclude].flatten : [],
110
+ options[:exclude_file]
111
+ )
76
112
 
77
- source = IMAP.new(options[:from], options[:from_user], options[:from_pass],
78
- :fast_scan => options[:fast_scan],
79
- :max_retries => options[:max_retries])
113
+ Net::IMAP.debug = true if @log.level == :insane
80
114
 
81
- dest = IMAP.new(options[:to], options[:to_user], options[:to_pass],
82
- :create_mailbox => !options[:no_create_folder],
115
+ imap_from = Larch::IMAP.new(uri_from,
116
+ :dry_run => options[:dry_run],
117
+ :fast_scan => options[:fast_scan],
118
+ :max_retries => options[:max_retries],
119
+ :ssl_certs => options[:ssl_certs] || nil,
120
+ :ssl_verify => options[:ssl_verify]
121
+ )
122
+
123
+ imap_to = Larch::IMAP.new(uri_to,
124
+ :create_mailbox => !options[:no_create_folder] && !options[:dry_run],
125
+ :dry_run => options[:dry_run],
83
126
  :fast_scan => options[:fast_scan],
84
- :max_retries => options[:max_retries])
85
-
86
- for sig in [:SIGINT, :SIGQUIT, :SIGTERM]
87
- trap(sig) { @log.fatal "Interrupted (#{sig})"; summary; exit }
127
+ :max_retries => options[:max_retries],
128
+ :ssl_certs => options[:ssl_certs] || nil,
129
+ :ssl_verify => options[:ssl_verify]
130
+ )
131
+
132
+ unless RUBY_PLATFORM =~ /mswin|mingw|bccwin|wince|java/
133
+ begin
134
+ for sig in [:SIGINT, :SIGQUIT, :SIGTERM]
135
+ trap(sig) { @log.fatal "Interrupted (#{sig})"; Kernel.exit }
136
+ end
137
+ rescue => e
138
+ end
88
139
  end
89
140
 
90
- copy(source, dest)
141
+ if options[:all_given]
142
+ copy_all(imap_from, imap_to)
143
+ elsif options[:all_subscribed_given]
144
+ copy_all(imap_from, imap_to, true)
145
+ else
146
+ copy_folder(imap_from, imap_to)
147
+ end
91
148
  end
data/lib/larch/errors.rb CHANGED
@@ -4,6 +4,7 @@ module Larch
4
4
  class IMAP
5
5
  class Error < Larch::Error; end
6
6
  class FatalError < Error; end
7
- class NotFoundError < Error; end
7
+ class MailboxNotFoundError < Error; end
8
+ class MessageNotFoundError < Error; end
8
9
  end
9
10
  end
@@ -0,0 +1,279 @@
1
+ module Larch; class IMAP
2
+
3
+ # Represents an IMAP mailbox.
4
+ class Mailbox
5
+ attr_reader :attr, :delim, :imap, :name, :state
6
+
7
+ # Regex to capture a Message-Id header.
8
+ REGEX_MESSAGE_ID = /message-id\s*:\s*(\S+)/i
9
+
10
+ # Minimum time (in seconds) allowed between mailbox scans.
11
+ SCAN_INTERVAL = 60
12
+
13
+ def initialize(imap, name, delim, subscribed, *attr)
14
+ raise ArgumentError, "must provide a Larch::IMAP instance" unless imap.is_a?(Larch::IMAP)
15
+
16
+ @imap = imap
17
+ @name = name
18
+ @delim = delim
19
+ @subscribed = subscribed
20
+ @attr = attr.flatten
21
+
22
+ @ids = {}
23
+ @last_id = 0
24
+ @last_scan = nil
25
+ @mutex = Mutex.new
26
+
27
+ # Valid mailbox states are :closed (no mailbox open), :examined (mailbox
28
+ # open and read-only), or :selected (mailbox open and read-write).
29
+ @state = :closed
30
+
31
+ # Create private convenience methods (debug, info, warn, etc.) to make
32
+ # logging easier.
33
+ Logger::LEVELS.each_key do |level|
34
+ Mailbox.class_eval do
35
+ define_method(level) do |msg|
36
+ Larch.log.log(level, "#{@imap.username}@#{@imap.host}: #{@name}: #{msg}")
37
+ end
38
+
39
+ private level
40
+ end
41
+ end
42
+ end
43
+
44
+ # Appends the specified Larch::IMAP::Message to this mailbox if it doesn't
45
+ # already exist. Returns +true+ if the message was appended successfully,
46
+ # +false+ if the message already exists in the mailbox.
47
+ def append(message)
48
+ raise ArgumentError, "must provide a Larch::IMAP::Message object" unless message.is_a?(Larch::IMAP::Message)
49
+ return false if has_message?(message)
50
+
51
+ @imap.safely do
52
+ unless imap_select(!!@imap.options[:create_mailbox])
53
+ raise Larch::IMAP::Error, "mailbox cannot contain messages: #{@name}"
54
+ end
55
+
56
+ debug "appending message: #{message.id}"
57
+ @imap.conn.append(@name, message.rfc822, message.flags, message.internaldate) unless @imap.options[:dry_run]
58
+ end
59
+
60
+ true
61
+ end
62
+ alias << append
63
+
64
+ # Iterates through Larch message ids in this mailbox, yielding each one to the
65
+ # provided block.
66
+ def each
67
+ scan
68
+ @ids.dup.each_key {|id| yield id }
69
+ end
70
+
71
+ # Gets a Net::IMAP::Envelope for the specified message id.
72
+ def envelope(message_id)
73
+ scan
74
+ raise Larch::IMAP::MessageNotFoundError, "message not found: #{message_id}" unless uid = @ids[message_id]
75
+
76
+ debug "fetching envelope: #{message_id}"
77
+ imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
78
+ end
79
+
80
+ # Fetches a Larch::IMAP::Message struct representing the message with the
81
+ # specified Larch message id.
82
+ def fetch(message_id, peek = false)
83
+ scan
84
+ raise Larch::IMAP::MessageNotFoundError, "message not found: #{message_id}" unless uid = @ids[message_id]
85
+
86
+ debug "#{peek ? 'peeking at' : 'fetching'} message: #{message_id}"
87
+ data = imap_uid_fetch([uid], [(peek ? 'BODY.PEEK[]' : 'BODY[]'), 'FLAGS', 'INTERNALDATE', 'ENVELOPE']).first
88
+
89
+ Message.new(message_id, data.attr['ENVELOPE'], data.attr['BODY[]'],
90
+ data.attr['FLAGS'], Time.parse(data.attr['INTERNALDATE']))
91
+ end
92
+ alias [] fetch
93
+
94
+ # Returns +true+ if a message with the specified Larch <em>message_id</em>
95
+ # exists in this mailbox, +false+ otherwise.
96
+ def has_message?(message_id)
97
+ scan
98
+ @ids.has_key?(message_id)
99
+ end
100
+
101
+ # Gets the number of messages in this mailbox.
102
+ def length
103
+ scan
104
+ @ids.length
105
+ end
106
+ alias size length
107
+
108
+ # Same as fetch, but doesn't mark the message as seen.
109
+ def peek(message_id)
110
+ fetch(message_id, true)
111
+ end
112
+
113
+ # Resets the mailbox state.
114
+ def reset
115
+ @mutex.synchronize { @state = :closed }
116
+ end
117
+
118
+ # Fetches message headers from this mailbox.
119
+ def scan
120
+ return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
121
+
122
+ begin
123
+ return unless imap_examine
124
+ rescue Error => e
125
+ return if @imap.options[:create_mailbox]
126
+ raise
127
+ end
128
+
129
+ last_id = @imap.safely { @imap.conn.responses['EXISTS'].last }
130
+ @mutex.synchronize { @last_scan = Time.now }
131
+ return if last_id == @last_id
132
+
133
+ range = (@last_id + 1)..last_id
134
+ @mutex.synchronize { @last_id = last_id }
135
+
136
+ info "fetching message headers #{range}" <<
137
+ (@imap.options[:fast_scan] ? ' (fast scan)' : '')
138
+
139
+ fields = if @imap.options[:fast_scan]
140
+ ['UID', 'RFC822.SIZE', 'INTERNALDATE']
141
+ else
142
+ "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] RFC822.SIZE INTERNALDATE)"
143
+ end
144
+
145
+ imap_fetch(range.begin..-1, fields).each do |data|
146
+ id = create_id(data)
147
+
148
+ unless uid = data.attr['UID']
149
+ error "UID not in IMAP response for message: #{id}"
150
+ next
151
+ end
152
+
153
+ if Larch.log.level == :debug && @ids.has_key?(id)
154
+ envelope = imap_uid_fetch([uid], 'ENVELOPE').first.attr['ENVELOPE']
155
+ debug "duplicate message? #{id} (Subject: #{envelope.subject})"
156
+ end
157
+
158
+ @mutex.synchronize { @ids[id] = uid }
159
+ end
160
+ end
161
+
162
+ # Subscribes to this mailbox.
163
+ def subscribe(force = false)
164
+ return if subscribed? && !force
165
+ @imap.safely { @imap.conn.subscribe(@name) } unless @imap.options[:dry_run]
166
+ @mutex.synchronize { @subscribed = true }
167
+ end
168
+
169
+ # Returns +true+ if this mailbox is subscribed, +false+ otherwise.
170
+ def subscribed?
171
+ @subscribed
172
+ end
173
+
174
+ # Unsubscribes from this mailbox.
175
+ def unsubscribe(force = false)
176
+ return unless subscribed? || force
177
+ @imap.safely { @imap.conn.unsubscribe(@name) } unless @imap.options[:dry_run]
178
+ @mutex.synchronize { @subscribed = false }
179
+ end
180
+
181
+ private
182
+
183
+ # Creates an id suitable for uniquely identifying a specific message across
184
+ # servers (we hope).
185
+ #
186
+ # If the given message data includes a valid Message-Id header, then that will
187
+ # be used to generate an MD5 hash. Otherwise, the hash will be generated based
188
+ # on the message's RFC822.SIZE and INTERNALDATE.
189
+ def create_id(data)
190
+ ['RFC822.SIZE', 'INTERNALDATE'].each do |a|
191
+ raise Error, "required data not in IMAP response: #{a}" unless data.attr[a]
192
+ end
193
+
194
+ if data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]'] =~ REGEX_MESSAGE_ID
195
+ Digest::MD5.hexdigest($1)
196
+ else
197
+ Digest::MD5.hexdigest(sprintf('%d%d', data.attr['RFC822.SIZE'],
198
+ Time.parse(data.attr['INTERNALDATE']).to_i))
199
+ end
200
+ end
201
+
202
+ # Examines the mailbox. If _force_ is true, the mailbox will be examined even
203
+ # if it is already selected (which isn't necessary unless you want to ensure
204
+ # that it's in a read-only state).
205
+ def imap_examine(force = false)
206
+ return false if @attr.include?(:Noselect)
207
+ return true if @state == :examined || (!force && @state == :selected)
208
+
209
+ @imap.safely do
210
+ begin
211
+ @mutex.synchronize { @state = :closed }
212
+
213
+ debug "examining mailbox"
214
+ @imap.conn.examine(@name)
215
+
216
+ @mutex.synchronize { @state = :examined }
217
+
218
+ rescue Net::IMAP::NoResponseError => e
219
+ raise Error, "unable to examine mailbox: #{e.message}"
220
+ end
221
+ end
222
+
223
+ return true
224
+ end
225
+
226
+ # Fetches the specified _fields_ for the specified _set_ of message sequence
227
+ # ids (either a Range or an Array of ids).
228
+ def imap_fetch(set, fields)
229
+ @imap.safely do
230
+ imap_examine
231
+ @imap.conn.fetch(set, fields)
232
+ end
233
+ end
234
+
235
+ # Selects the mailbox if it is not already selected. If the mailbox does not
236
+ # exist and _create_ is +true+, it will be created. Otherwise, a
237
+ # Larch::IMAP::Error will be raised.
238
+ def imap_select(create = false)
239
+ return false if @attr.include?(:Noselect)
240
+ return true if @state == :selected
241
+
242
+ @imap.safely do
243
+ begin
244
+ @mutex.synchronize { @state = :closed }
245
+
246
+ debug "selecting mailbox"
247
+ @imap.conn.select(@name)
248
+
249
+ @mutex.synchronize { @state = :selected }
250
+
251
+ rescue Net::IMAP::NoResponseError => e
252
+ raise Error, "unable to select mailbox: #{e.message}" unless create
253
+
254
+ info "creating mailbox: #{@name}"
255
+
256
+ begin
257
+ @imap.conn.create(@name) unless @imap.options[:dry_run]
258
+ retry
259
+ rescue => e
260
+ raise Error, "unable to create mailbox: #{e.message}"
261
+ end
262
+ end
263
+ end
264
+
265
+ return true
266
+ end
267
+
268
+ # Fetches the specified _fields_ for the specified _set_ of UIDs (either a
269
+ # Range or an Array of UIDs).
270
+ def imap_uid_fetch(set, fields)
271
+ @imap.safely do
272
+ imap_examine
273
+ @imap.conn.uid_fetch(set, fields)
274
+ end
275
+ end
276
+
277
+ end
278
+
279
+ end; end