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/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