larch-1.1.2/0000755000175000017500000000000012314171101012125 5ustar tinchotincholarch-1.1.2/metadata.yml0000644000175000017500000000553112314171101014434 0ustar tinchotincho--- !ruby/object:Gem::Specification name: larch version: !ruby/object:Gem::Version version: 1.1.2 prerelease: platform: ruby authors: - Ryan Grove autorequire: bindir: bin cert_chain: [] date: 2013-01-24 00:00:00.000000000 Z dependencies: - !ruby/object:Gem::Dependency name: highline requirement: !ruby/object:Gem::Requirement none: false requirements: - - ~> - !ruby/object:Gem::Version version: '1.5' type: :runtime prerelease: false version_requirements: !ruby/object:Gem::Requirement none: false requirements: - - ~> - !ruby/object:Gem::Version version: '1.5' - !ruby/object:Gem::Dependency name: sequel requirement: !ruby/object:Gem::Requirement none: false requirements: - - ~> - !ruby/object:Gem::Version version: '3.14' type: :runtime prerelease: false version_requirements: !ruby/object:Gem::Requirement none: false requirements: - - ~> - !ruby/object:Gem::Version version: '3.14' - !ruby/object:Gem::Dependency name: sqlite3 requirement: !ruby/object:Gem::Requirement none: false requirements: - - ~> - !ruby/object:Gem::Version version: '1.3' type: :runtime prerelease: false version_requirements: !ruby/object:Gem::Requirement none: false requirements: - - ~> - !ruby/object:Gem::Version version: '1.3' - !ruby/object:Gem::Dependency name: trollop requirement: !ruby/object:Gem::Requirement none: false requirements: - - ~> - !ruby/object:Gem::Version version: '1.13' type: :runtime prerelease: false version_requirements: !ruby/object:Gem::Requirement none: false requirements: - - ~> - !ruby/object:Gem::Version version: '1.13' description: email: ryan@wonko.com executables: - larch extensions: [] extra_rdoc_files: [] files: - HISTORY - LICENSE - README.rdoc - bin/larch - lib/larch/config.rb - lib/larch/db/account.rb - lib/larch/db/mailbox.rb - lib/larch/db/message.rb - lib/larch/db/migrate/001_create_schema.rb - lib/larch/db/migrate/002_add_timestamps.rb - lib/larch/errors.rb - lib/larch/imap/mailbox.rb - lib/larch/imap.rb - lib/larch/logger.rb - lib/larch/monkeypatch/net/imap.rb - lib/larch/version.rb - lib/larch.rb homepage: https://github.com/rgrove/larch licenses: [] post_install_message: rdoc_options: [] require_paths: - lib required_ruby_version: !ruby/object:Gem::Requirement none: false requirements: - - ! '>=' - !ruby/object:Gem::Version version: 1.8.6 required_rubygems_version: !ruby/object:Gem::Requirement none: false requirements: - - ! '>=' - !ruby/object:Gem::Version version: '0' requirements: [] rubyforge_project: rubygems_version: 1.8.24 signing_key: specification_version: 3 summary: Larch copies messages from one IMAP server to another. Awesomely. test_files: [] larch-1.1.2/lib/0000755000175000017500000000000012314171101012673 5ustar tinchotincholarch-1.1.2/lib/larch.rb0000644000175000017500000002140012314171101014306 0ustar tinchotinchorequire 'cgi' require 'digest/md5' require 'fileutils' require 'net/imap' require 'time' require 'uri' require 'yaml' require 'sequel' require 'sequel/extensions/migration' require 'larch/monkeypatch/net/imap' require 'larch/config' require 'larch/errors' require 'larch/imap' require 'larch/imap/mailbox' require 'larch/logger' require 'larch/version' module Larch class << self attr_reader :config, :db, :exclude, :log EXCLUDE_COMMENT = /#.*$/ EXCLUDE_REGEX = /^\s*\/(.*)\/\s*/ GLOB_PATTERNS = {'*' => '.*', '?' => '.'} LIB_DIR = File.join(File.dirname(File.expand_path(__FILE__)), 'larch') def init(config) raise ArgumentError, "config must be a Larch::Config instance" unless config.is_a?(Config) @config = config @log = Logger.new(@config[:verbosity]) @db = open_db(@config[:database]) parse_exclusions Net::IMAP.debug = true if @log.level == :insane # Stats @copied = 0 @deleted = 0 @failed = 0 @total = 0 end # Recursively copies all messages in all folders from the source to the # destination. def copy_all(imap_from, imap_to, subscribed_only = false) raise ArgumentError, "imap_from must be a Larch::IMAP instance" unless imap_from.is_a?(IMAP) raise ArgumentError, "imap_to must be a Larch::IMAP instance" unless imap_to.is_a?(IMAP) @copied = 0 @deleted = 0 @failed = 0 @total = 0 imap_from.each_mailbox do |mailbox_from| next if excluded?(mailbox_from.name) next if subscribed_only && !mailbox_from.subscribed? if imap_to.uri_mailbox mailbox_to = imap_to.mailbox(imap_to.uri_mailbox) else mailbox_to = imap_to.mailbox(mailbox_from.name, mailbox_from.delim) end mailbox_to.subscribe if mailbox_from.subscribed? copy_messages(mailbox_from, mailbox_to) end rescue => e @log.fatal e.message ensure summary db_maintenance end # Copies the messages in a single IMAP folder and all its subfolders # (recursively) from the source to the destination. def copy_folder(imap_from, imap_to) raise ArgumentError, "imap_from must be a Larch::IMAP instance" unless imap_from.is_a?(IMAP) raise ArgumentError, "imap_to must be a Larch::IMAP instance" unless imap_to.is_a?(IMAP) @copied = 0 @deleted = 0 @failed = 0 @total = 0 mailbox_from = imap_from.mailbox(imap_from.uri_mailbox || 'INBOX') mailbox_to = imap_to.mailbox(imap_to.uri_mailbox || 'INBOX') copy_mailbox(mailbox_from, mailbox_to) imap_from.disconnect imap_to.disconnect rescue => e @log.fatal e.message ensure summary db_maintenance end # Opens a connection to the Larch message database, creating it if # necessary. def open_db(database) unless database == ':memory:' filename = File.expand_path(database) directory = File.dirname(filename) unless File.exist?(directory) FileUtils.mkdir_p(directory) File.chmod(0700, directory) end end begin db = Sequel.sqlite(:database => filename) db.test_connection rescue => e @log.fatal "unable to open message database: #{e}" abort end # Ensure that the database schema is up to date. migration_dir = File.join(LIB_DIR, 'db', 'migrate') begin Sequel::Migrator.apply(db, migration_dir) rescue => e @log.fatal "unable to migrate message database: #{e}" abort end require 'larch/db/message' require 'larch/db/mailbox' require 'larch/db/account' db end def summary @log.info "#{@copied} message(s) copied, #{@failed} failed, #{@deleted} deleted out of #{@total} total" end private def copy_mailbox(mailbox_from, mailbox_to) raise ArgumentError, "mailbox_from must be a Larch::IMAP::Mailbox instance" unless mailbox_from.is_a?(Larch::IMAP::Mailbox) raise ArgumentError, "mailbox_to must be a Larch::IMAP::Mailbox instance" unless mailbox_to.is_a?(Larch::IMAP::Mailbox) return if excluded?(mailbox_from.name) || excluded?(mailbox_to.name) mailbox_to.subscribe if mailbox_from.subscribed? copy_messages(mailbox_from, mailbox_to) unless @config['no-recurse'] mailbox_from.each_mailbox do |child_from| next if excluded?(child_from.name) child_to = mailbox_to.imap.mailbox(child_from.name, child_from.delim) copy_mailbox(child_from, child_to) end end end def copy_messages(mailbox_from, mailbox_to) raise ArgumentError, "mailbox_from must be a Larch::IMAP::Mailbox instance" unless mailbox_from.is_a?(Larch::IMAP::Mailbox) raise ArgumentError, "mailbox_to must be a Larch::IMAP::Mailbox instance" unless mailbox_to.is_a?(Larch::IMAP::Mailbox) return if excluded?(mailbox_from.name) || excluded?(mailbox_to.name) imap_from = mailbox_from.imap imap_to = mailbox_to.imap @log.info "#{imap_from.host}/#{mailbox_from.name} -> #{imap_to.host}/#{mailbox_to.name}" @total += mailbox_from.length mailbox_from.each_db_message do |from_db_message| guid = from_db_message.guid uid = from_db_message.uid if mailbox_to.has_guid?(guid) begin if @config['sync_flags'] to_db_message = mailbox_to.fetch_db_message(guid) if to_db_message.flags != from_db_message.flags new_flags = from_db_message.flags_str new_flags = '(none)' if new_flags.empty? @log.info "[>] syncing flags: uid #{uid}: #{new_flags}" mailbox_to.set_flags(guid, from_db_message.flags) end end if @config['delete'] && !from_db_message.flags.include?(:Deleted) @log.info "[<] deleting uid #{uid} (already exists at destination)" @deleted += 1 if mailbox_from.delete_message(guid) end rescue Larch::IMAP::Error => e @log.error e.message end next end begin unless msg = mailbox_from.peek(guid) @failed += 1 next end if msg.envelope.from env_from = msg.envelope.from.first from = "#{env_from.mailbox}@#{env_from.host}" else from = '?' end @log.info "[>] copying uid #{uid}: #{from} - #{msg.envelope.subject}" mailbox_to << msg @copied += 1 if @config['delete'] @log.info "[<] deleting uid #{uid}" @deleted += 1 if mailbox_from.delete_message(guid) end rescue Larch::IMAP::Error => e @failed += 1 @log.error e.message next end end if @config['expunge'] begin @log.debug "[<] expunging deleted messages" mailbox_from.expunge rescue Larch::IMAP::Error => e @log.error e.message end end rescue Larch::IMAP::Error => e @log.error e.message end def db_maintenance @log.debug 'performing database maintenance' # Remove accounts that haven't been used in over 30 days. Database::Account.filter(:updated_at => nil).destroy Database::Account.filter('? - updated_at >= 2592000', Time.now.to_i).destroy # Release unused disk space and defragment the database. @db.run('VACUUM') end def excluded?(name) name = name.downcase @exclude.each do |e| return true if (e.is_a?(Regexp) ? !!(name =~ e) : File.fnmatch?(e, name)) end return false end def glob_to_regex(str) str.gsub!(/(.)/) {|c| GLOB_PATTERNS[$1] || Regexp.escape(c) } Regexp.new("^#{str}$", Regexp::IGNORECASE) end def load_exclude_file(filename) @exclude ||= [] lineno = 0 File.open(filename, 'rb') do |f| f.each do |line| lineno += 1 # Strip comments. line.sub!(EXCLUDE_COMMENT, '') line.strip! # Skip empty lines. next if line.empty? if line =~ EXCLUDE_REGEX @exclude << Regexp.new($1, Regexp::IGNORECASE) else @exclude << glob_to_regex(line) end end end rescue => e raise Larch::IMAP::FatalError, "error in exclude file at line #{lineno}: #{e}" end def parse_exclusions @exclude = @config[:exclude].map do |e| if e =~ EXCLUDE_REGEX Regexp.new($1, Regexp::IGNORECASE) else glob_to_regex(e.strip) end end load_exclude_file(@config[:exclude_file]) if @config[:exclude_file] end end end larch-1.1.2/lib/larch/0000755000175000017500000000000012314171101013764 5ustar tinchotincholarch-1.1.2/lib/larch/version.rb0000644000175000017500000000045512314171101016002 0ustar tinchotinchomodule Larch APP_NAME = 'Larch' APP_VERSION = '1.1.2' APP_AUTHOR = 'Ryan Grove' APP_EMAIL = 'ryan@wonko.com' APP_URL = 'https://github.com/rgrove/larch/' APP_COPYRIGHT = 'Copyright (c) 2013 Ryan Grove . All ' << 'rights reserved.' end larch-1.1.2/lib/larch/monkeypatch/0000755000175000017500000000000012314171101016306 5ustar tinchotincholarch-1.1.2/lib/larch/monkeypatch/net/0000755000175000017500000000000012314171101017074 5ustar tinchotincholarch-1.1.2/lib/larch/monkeypatch/net/imap.rb0000644000175000017500000000472012314171101020352 0ustar tinchotincho# Monkeypatches for Net::IMAP. module Net # :nodoc: class IMAP # :nodoc: class ResponseParser # :nodoc: private # Fixes an issue with bogus STATUS responses from Exchange that contain # trailing whitespace. This monkeypatch works cleanly against Ruby 1.8.x # and 1.9.x. def status_response token = match(T_ATOM) name = token.value.upcase match(T_SPACE) mailbox = astring match(T_SPACE) match(T_LPAR) attr = {} while true token = lookahead case token.symbol when T_RPAR shift_token break when T_SPACE shift_token end token = match(T_ATOM) key = token.value.upcase match(T_SPACE) val = number attr[key] = val end # Monkeypatch starts here... token = lookahead shift_token if token.symbol == T_SPACE # ...and ends here. data = StatusData.new(mailbox, attr) return UntaggedResponse.new(name, data, @str) end if RUBY_VERSION <= '1.9.1' # Monkeypatches Net::IMAP in Ruby <= 1.9.1 to fix broken response # handling, particularly when changing mailboxes on a Dovecot 1.2+ # server. # # This monkeypatch shouldn't be necessary in Ruby 1.9.2 and higher. # It's included in Ruby 1.9 SVN trunk as of 2010-02-08. def resp_text_code @lex_state = EXPR_BEG match(T_LBRA) token = match(T_ATOM) name = token.value.upcase case name when /\A(?:ALERT|PARSE|READ-ONLY|READ-WRITE|TRYCREATE|NOMODSEQ)\z/n result = ResponseCode.new(name, nil) when /\A(?:PERMANENTFLAGS)\z/n match(T_SPACE) result = ResponseCode.new(name, flag_list) when /\A(?:UIDVALIDITY|UIDNEXT|UNSEEN)\z/n match(T_SPACE) result = ResponseCode.new(name, number) else token = lookahead if token.symbol == T_SPACE shift_token @lex_state = EXPR_CTEXT token = match(T_TEXT) @lex_state = EXPR_BEG result = ResponseCode.new(name, token.value) else result = ResponseCode.new(name, nil) end end match(T_RBRA) @lex_state = EXPR_RTEXT return result end end end end end larch-1.1.2/lib/larch/logger.rb0000644000175000017500000000207312314171101015572 0ustar tinchotinchomodule Larch class Logger attr_reader :level, :output LEVELS = { :fatal => 0, :error => 1, :warn => 2, :warning => 2, :info => 3, :debug => 4, :insane => 5 } def initialize(level = :info, output = $stdout) self.level = level.to_sym self.output = output end def const_missing(name) return LEVELS[name] if LEVELS.key?(name) raise NameError, "uninitialized constant: #{name}" end def method_missing(name, *args) return log(name, *args) if LEVELS.key?(name) raise NoMethodError, "undefined method: #{name}" end def level=(level) raise ArgumentError, "invalid log level: #{level}" unless LEVELS.key?(level) @level = level end def log(level, msg) return true if LEVELS[level] > LEVELS[@level] || msg.nil? || msg.empty? @output.puts "[#{Time.new.strftime('%H:%M:%S')}] [#{level}] #{msg}" true rescue => e false end def output=(output) raise ArgumentError, "output must be an instance of class IO" unless output.is_a?(IO) @output = output end end end larch-1.1.2/lib/larch/imap.rb0000644000175000017500000002457712314171101015256 0ustar tinchotinchomodule Larch # Manages a connection to an IMAP server and all the glorious fun that entails. # # This class borrows heavily from Sup, the source code of which should be # required reading if you're doing anything with IMAP in Ruby: # http://sup.rubyforge.org class IMAP attr_reader :conn, :db_account, :mailboxes, :options, :quirks # URI format validation regex. REGEX_URI = URI.regexp(['imap', 'imaps']) # Larch::IMAP::Message represents a transferable IMAP message which can be # passed between Larch::IMAP instances. Message = Struct.new(:guid, :envelope, :rfc822, :flags, :internaldate) # Initializes a new Larch::IMAP instance that will connect to the specified # IMAP URI. # # In addition to the URI, the following options may be specified: # # [:create_mailbox] # If +true+, mailboxes that don't already exist will be created if # necessary. # # [:dry_run] # If +true+, read-only operations will be performed as usual and all change # operations will be simulated, but no changes will actually be made. Note # that it's not actually possible to simulate mailbox creation, so # +:dry_run+ mode always behaves as if +:create_mailbox+ is +false+. # # [:log_label] # Label to use for this connection in log output. If not specified, the # default label is "[username@host]". # # [:max_retries] # After a recoverable error occurs, retry the operation up to this many # times. Default is 3. # # [:ssl_certs] # Path to a trusted certificate bundle to use to verify server SSL # certificates. You can download a bundle of certificate authority root # certs at http://curl.haxx.se/ca/cacert.pem (it's up to you to verify that # this bundle hasn't been tampered with, however; don't trust it blindly). # # [:ssl_verify] # If +true+, server SSL certificates will be verified against the trusted # certificate bundle specified in +ssl_certs+. By default, server SSL # certificates are not verified. # def initialize(uri, options = {}) raise ArgumentError, "not an IMAP URI: #{uri}" unless uri.is_a?(URI) || uri =~ REGEX_URI raise ArgumentError, "options must be a Hash" unless options.is_a?(Hash) @uri = uri.is_a?(URI) ? uri : URI(uri) @options = { :log_label => "[#{username}@#{host}]", :max_retries => 3, :ssl_verify => false }.merge(options) raise ArgumentError, "must provide a username and password" unless @uri.user && @uri.password @conn = nil @mailboxes = {} @quirks = { :gmail => false, :yahoo => false } @db_account = Database::Account.find_or_create( :hostname => host, :username => username ) @db_account.touch # Create private convenience methods (debug, info, warn, etc.) to make # logging easier. Logger::LEVELS.each_key do |level| next if IMAP.private_method_defined?(level) IMAP.class_eval do define_method(level) do |msg| Larch.log.log(level, "#{@options[:log_label]} #{msg}") end private level end end end # Connects to the IMAP server and logs in if a connection hasn't already been # established. def connect return if @conn safely {} # connect, but do nothing else end # Gets the server's mailbox hierarchy delimiter. def delim @delim ||= safely { @conn.list('', '')[0].delim || '.'} end # Closes the IMAP connection if one is currently open. def disconnect return unless @conn begin @conn.disconnect rescue Errno::ENOTCONN => e debug "#{e.class.name}: #{e.message}" end reset info "disconnected" end # Iterates through all mailboxes in the account, yielding each one as a # Larch::IMAP::Mailbox instance to the given block. def each_mailbox update_mailboxes @mailboxes.each_value {|mailbox| yield mailbox } end # Gets the IMAP hostname. def host @uri.host end # Gets a Larch::IMAP::Mailbox instance representing the specified mailbox. If # the mailbox doesn't exist and the :create_mailbox option is # +false+, or if :create_mailbox is +true+ and mailbox creation # fails, a Larch::IMAP::MailboxNotFoundError will be raised. def mailbox(name, delim = '/') retries = 0 name.gsub!(/^(inbox\/?)/i){ $1.upcase } name.gsub!(delim, self.delim) # Gmail doesn't allow folders with leading or trailing whitespace. name.strip! if @quirks[:gmail] #Rackspace namespaces everything under INDEX. name.sub!(/^|inbox\./i, "INBOX.") if @quirks[:rackspace] && name != 'INBOX' begin @mailboxes.fetch(name) do update_mailboxes return @mailboxes[name] if @mailboxes.has_key?(name) raise MailboxNotFoundError, "mailbox not found: #{name}" end rescue MailboxNotFoundError => e raise unless @options[:create_mailbox] && retries == 0 info "creating mailbox: #{name}" safely { @conn.create(Net::IMAP.encode_utf7(name)) } unless @options[:dry_run] retries += 1 retry end end # Sends an IMAP NOOP command. def noop safely { @conn.noop } end # Gets the IMAP password. def password CGI.unescape(@uri.password) end # Gets the IMAP port number. def port @uri.port || (ssl? ? 993 : 143) end # Connect if necessary, execute the given block, retry if a recoverable error # occurs, die if an unrecoverable error occurs. def safely safe_connect retries = 0 begin yield rescue Errno::ECONNABORTED, Errno::ECONNRESET, Errno::ENOTCONN, Errno::EPIPE, Errno::ETIMEDOUT, IOError, Net::IMAP::ByeResponseError, OpenSSL::SSL::SSLError => e raise unless (retries += 1) <= @options[:max_retries] warning "#{e.class.name}: #{e.message} (reconnecting)" reset sleep 1 * retries safe_connect retry rescue Net::IMAP::BadResponseError, Net::IMAP::NoResponseError, Net::IMAP::ResponseParseError => e raise unless (retries += 1) <= @options[:max_retries] warning "#{e.class.name}: #{e.message} (will retry)" sleep 1 * retries retry end rescue Larch::Error => e raise rescue Net::IMAP::Error => e raise Error, "#{e.class.name}: #{e.message} (giving up)" rescue => e raise FatalError, "#{e.class.name}: #{e.message} (cannot recover)" end # Gets the SSL status. def ssl? @uri.scheme == 'imaps' end # Gets the IMAP URI. def uri @uri.to_s end # Gets the IMAP mailbox specified in the URI, or +nil+ if none. def uri_mailbox mb = @uri.path[1..-1] mb.nil? || mb.empty? ? nil : CGI.unescape(mb) end # Gets the IMAP username. def username CGI.unescape(@uri.user) end private # Tries to identify server implementations with certain quirks that we'll need # to work around. def check_quirks return unless @conn && @conn.greeting.kind_of?(Net::IMAP::UntaggedResponse) && @conn.greeting.data.kind_of?(Net::IMAP::ResponseText) if @conn.greeting.data.text =~ /^Gimap ready/ @quirks[:gmail] = true debug "looks like Gmail" elsif host =~ /^imap(?:-ssl)?\.mail\.yahoo\.com$/ @quirks[:yahoo] = true debug "looks like Yahoo! Mail" elsif host =~ /emailsrvr\.com/ @quirks[:rackspace] = true debug "looks like Rackspace Mail" end end # Resets the connection and mailbox state. def reset @conn = nil @mailboxes.each_value {|mb| mb.reset } end def safe_connect return if @conn retries = 0 begin unsafe_connect rescue Errno::ECONNRESET, Errno::EPIPE, Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e raise unless (retries += 1) <= @options[:max_retries] # Special check to ensure that we don't retry on OpenSSL certificate # verification errors. raise if e.is_a?(OpenSSL::SSL::SSLError) && e.message =~ /certificate verify failed/ warning "#{e.class.name}: #{e.message} (will retry)" reset sleep 1 * retries retry end rescue => e raise FatalError, "#{e.class.name}: #{e.message} (cannot recover)" end def unsafe_connect debug "connecting..." exception = nil Thread.new do begin @conn = Net::IMAP.new(host, port, ssl?, ssl? && @options[:ssl_verify] ? @options[:ssl_certs] : nil, @options[:ssl_verify]) info "connected to #{host} on port #{port}" << (ssl? ? ' using SSL' : '') check_quirks # If this is Yahoo! Mail, we have to send a special command before # it'll let us authenticate. if @quirks[:yahoo] @conn.instance_eval { send_command('ID ("guid" "1")') } end auth_methods = ['PLAIN'] tried = [] capability = @conn.capability ['LOGIN', 'CRAM-MD5'].each do |method| auth_methods << method if capability.include?("AUTH=#{method}") end begin tried << method = auth_methods.pop debug "authenticating using #{method}" if method == 'PLAIN' @conn.login(username, password) else @conn.authenticate(method, username, password) end debug "authenticated using #{method}" rescue Net::IMAP::BadResponseError, Net::IMAP::NoResponseError => e debug "#{method} auth failed: #{e.message}" retry unless auth_methods.empty? raise e, "#{e.message} (tried #{tried.join(', ')})" end rescue => e exception = e end end.join raise exception if exception end def update_mailboxes debug "updating mailboxes" all = safely { @conn.list('', '*') } || [] subscribed = safely { @conn.lsub('', '*') } || [] # Remove cached mailboxes that no longer exist. @mailboxes.delete_if {|k, v| !all.any?{|mb| Net::IMAP.decode_utf7(mb.name) == k}} # Update cached mailboxes. all.each do |mb| name = Net::IMAP.decode_utf7(mb.name) name = 'INBOX' if name.downcase == 'inbox' @mailboxes[name] ||= Mailbox.new(self, name, mb.delim || '.', subscribed.any?{|s| s.name == mb.name}, mb.attr) end # Remove mailboxes that no longer exist from the database. @db_account.mailboxes_dataset.all do |db_mailbox| db_mailbox.destroy unless @mailboxes.has_key?(db_mailbox.name) end end end end larch-1.1.2/lib/larch/imap/0000755000175000017500000000000012314171101014712 5ustar tinchotincholarch-1.1.2/lib/larch/imap/mailbox.rb0000644000175000017500000004772412314171101016710 0ustar tinchotinchomodule Larch; class IMAP # Represents an IMAP mailbox. class Mailbox attr_reader :attr, :db_mailbox, :delim, :flags, :imap, :name, :perm_flags, :state, :subscribed # Maximum number of message headers to fetch with a single IMAP command. FETCH_BLOCK_SIZE = 1024 # Regex to capture a Message-Id header. REGEX_MESSAGE_ID = /message-id\s*:\s*(\S+)/i # Minimum time (in seconds) allowed between mailbox scans. SCAN_INTERVAL = 60 def initialize(imap, name, delim, subscribed, *attr) raise ArgumentError, "must provide a Larch::IMAP instance" unless imap.is_a?(Larch::IMAP) @attr = attr.flatten @delim = delim @flags = [] @imap = imap @last_scan = nil @name = name @name_utf7 = Net::IMAP.encode_utf7(@name) @perm_flags = [] @subscribed = subscribed # Valid mailbox states are :closed (no mailbox open), :examined (mailbox # open and read-only), or :selected (mailbox open and read-write). @state = :closed # Create/update this mailbox in the database. mb_data = { :name => @name, :delim => @delim, :attr => @attr.map{|a| a.to_s }.join(','), :subscribed => @subscribed ? 1 : 0 } @db_mailbox = imap.db_account.mailboxes_dataset.filter(:name => @name).first if @db_mailbox @db_mailbox.update(mb_data) else @db_mailbox = Database::Mailbox.create(mb_data) imap.db_account.add_mailbox(@db_mailbox) end # Create private convenience methods (debug, info, warn, etc.) to make # logging easier. Logger::LEVELS.each_key do |level| next if Mailbox.private_method_defined?(level) Mailbox.class_eval do define_method(level) do |msg| Larch.log.log(level, "#{@imap.options[:log_label]} #{@name}: #{msg}") end private level end end end # Appends the specified Larch::IMAP::Message to this mailbox if it doesn't # already exist. Returns +true+ if the message was appended successfully, # +false+ if the message already exists in the mailbox. def append(message) raise ArgumentError, "must provide a Larch::IMAP::Message object" unless message.is_a?(Larch::IMAP::Message) return false if has_guid?(message.guid) @imap.safely do unless imap_select(!!@imap.options[:create_mailbox]) raise Larch::IMAP::Error, "mailbox cannot contain messages: #{@name}" end debug "appending message: #{message.guid}" @imap.conn.append(@name_utf7, message.rfc822, get_supported_flags(message.flags), message.internaldate) unless @imap.options[:dry_run] end true end alias << append # Deletes the message in this mailbox with the specified guid. Returns +true+ # on success, +false+ on failure. def delete_message(guid) if @imap.quirks[:gmail] return false unless db_message = fetch_db_message(guid) debug "moving message to Gmail trash: #{guid}" @imap.safely { @imap.conn.uid_copy(db_message.uid, '[Gmail]/Trash') } && set_flags(guid, [:Deleted], true) else set_flags(guid, [:Deleted], true) end end # Iterates through messages in this mailbox, yielding a # Larch::Database::Message object for each to the provided block. def each_db_message # :yields: db_message scan @db_mailbox.messages_dataset.all {|db_message| yield db_message } end # Iterates through messages in this mailbox, yielding the Larch message guid # of each to the provided block. def each_guid # :yields: guid each_db_message {|db_message| yield db_message.guid } end # Iterates through mailboxes that are first-level children of this mailbox, # yielding a Larch::IMAP::Mailbox object for each to the provided block. def each_mailbox # :yields: mailbox mailboxes.each {|mb| yield mb } end # Expunges this mailbox, permanently removing all messages with the \Deleted # flag. def expunge return false unless imap_select @imap.safely do debug "expunging deleted messages" @last_scan = nil @imap.conn.expunge unless @imap.options[:dry_run] end end # Returns a Larch::IMAP::Message struct representing the message with the # specified Larch _guid_, or +nil+ if the specified guid was not found in this # mailbox. def fetch(guid, peek = false) scan unless db_message = fetch_db_message(guid) warning "message not found in local db: #{guid}" return nil end debug "#{peek ? 'peeking at' : 'fetching'} message: #{guid}" imap_uid_fetch([db_message.uid], [(peek ? 'BODY.PEEK[]' : 'BODY[]'), 'FLAGS', 'INTERNALDATE', 'ENVELOPE']) do |fetch_data| data = fetch_data.first check_response_fields(data, 'BODY[]', 'FLAGS', 'INTERNALDATE', 'ENVELOPE') return Message.new(guid, data.attr['ENVELOPE'], data.attr['BODY[]'], data.attr['FLAGS'], Time.parse(data.attr['INTERNALDATE'])) end warning "message not found on server: #{guid}" return nil end alias [] fetch # Returns a Larch::Database::Message object representing the message with the # specified Larch _guid_, or +nil+ if the specified guide was not found in # this mailbox. def fetch_db_message(guid) scan @db_mailbox.messages_dataset.filter(:guid => guid).first end # Returns +true+ if a message with the specified Larch guid exists in this # mailbox, +false+ otherwise. def has_guid?(guid) scan @db_mailbox.messages_dataset.filter(:guid => guid).count > 0 end # Gets the number of messages in this mailbox. def length scan @db_mailbox.messages_dataset.count end alias size length # Returns an Array of Larch::IMAP::Mailbox objects representing mailboxes that # are first-level children of this mailbox. def mailboxes return [] if @attr.include?(:Noinferiors) all = @imap.safely{ @imap.conn.list('', "#{@name_utf7}#{@delim}%") } || [] subscribed = @imap.safely{ @imap.conn.lsub('', "#{@name_utf7}#{@delim}%") } || [] all.map{|mb| Mailbox.new(@imap, mb.name, mb.delim, subscribed.any?{|s| s.name == mb.name}, mb.attr) } end # Same as fetch, but doesn't mark the message as seen. def peek(guid) fetch(guid, true) end # Resets the mailbox state. def reset @state = :closed end # Fetches message headers from this mailbox. def scan now = Time.now.to_i return if @last_scan && (now - @last_scan) < SCAN_INTERVAL first_scan = @last_scan.nil? @last_scan = now # Compare the mailbox's current status with its last known status. begin return unless status = imap_status('MESSAGES', 'UIDNEXT', 'UIDVALIDITY') rescue Error => e return if @imap.options[:create_mailbox] raise end flag_range = nil full_range = nil if @db_mailbox.uidvalidity && @db_mailbox.uidnext && status['UIDVALIDITY'] == @db_mailbox.uidvalidity # The UIDVALIDITY is the same as what we saw last time we scanned this # mailbox, which means that all the existing messages in the database are # still valid. We only need to request headers for new messages. # # If this is the first scan of this mailbox during this Larch session, # then we'll also update the flags of all messages in the mailbox. flag_range = 1...@db_mailbox.uidnext if first_scan full_range = @db_mailbox.uidnext...status['UIDNEXT'] else # The UIDVALIDITY has changed or this is the first time we've scanned this # mailbox (ever). Either way, all existing messages in the database are no # longer valid, so we have to throw them out and re-request everything. @db_mailbox.remove_all_messages full_range = 1...status['UIDNEXT'] end @db_mailbox.update(:uidvalidity => status['UIDVALIDITY']) need_flag_scan = flag_range && flag_range.max && flag_range.min && flag_range.max - flag_range.min >= 0 need_full_scan = full_range && full_range.max && full_range.min && full_range.max - full_range.min >= 0 return unless need_flag_scan || need_full_scan fetch_flags(flag_range) if need_flag_scan if need_full_scan fetch_headers(full_range, { :progress_start => @db_mailbox.messages_dataset.count + 1, :progress_total => status['MESSAGES'] }) end @db_mailbox.update(:uidnext => status['UIDNEXT']) return end # Sets the IMAP flags for the message specified by _guid_. _flags_ should be # an array of symbols for standard flags, strings for custom flags. # # If _merge_ is +true+, the specified flags will be merged with the message's # existing flags. Otherwise, all existing flags will be cleared and replaced # with the specified flags. # # Note that the :Recent flag cannot be manually set or removed. # # Returns +true+ on success, +false+ on failure. def set_flags(guid, flags, merge = false) raise ArgumentError, "flags must be an Array" unless flags.is_a?(Array) return false unless db_message = fetch_db_message(guid) merged_flags = merge ? (db_message.flags + flags).uniq : flags supported_flags = get_supported_flags(merged_flags) return true if db_message.flags == supported_flags return false if !imap_select @imap.safely { @imap.conn.uid_store(db_message.uid, 'FLAGS.SILENT', supported_flags) } unless @imap.options[:dry_run] true end # Subscribes to this mailbox. def subscribe(force = false) return false if subscribed? && !force @imap.safely { @imap.conn.subscribe(@name_utf7) } unless @imap.options[:dry_run] @subscribed = true @db_mailbox.update(:subscribed => 1) true end # Returns +true+ if this mailbox is subscribed, +false+ otherwise. def subscribed? @subscribed end # Unsubscribes from this mailbox. def unsubscribe(force = false) return false unless subscribed? || force @imap.safely { @imap.conn.unsubscribe(@name_utf7) } unless @imap.options[:dry_run] @subscribed = false @db_mailbox.update(:subscribed => 0) true end private # Checks the specified Net::IMAP::FetchData object and raises a # Larch::IMAP::Error unless it contains all the specified _fields_. # # _data_ can be a single object or an Array of objects; if it's an Array, then # only the first object in the Array will be checked. def check_response_fields(data, *fields) check_data = data.is_a?(Array) ? data.first : data fields.each do |f| raise Error, "required data not in IMAP response: #{f}" unless check_data.attr.has_key?(f) end true end # Creates a globally unique id suitable for identifying a specific message # on any mail server (we hope) based on the given IMAP FETCH _data_. # # If the given message data includes a valid Message-Id header, then that will # be used to generate an MD5 hash. Otherwise, the hash will be generated based # on the message's RFC822.SIZE and INTERNALDATE. def create_guid(data) if message_id = parse_message_id(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']) Digest::MD5.hexdigest(message_id) else check_response_fields(data, 'RFC822.SIZE', 'INTERNALDATE') Digest::MD5.hexdigest(sprintf('%d%d', data.attr['RFC822.SIZE'], Time.parse(data.attr['INTERNALDATE']).to_i)) end end # Returns only the flags from the specified _flags_ array that can be set in # this mailbox. Emits a warning message for any unsupported flags. def get_supported_flags(flags) supported_flags = flags.dup supported_flags.delete_if do |flag| # The \Recent flag is read-only, so we shouldn't try to set it. next true if flag == :Recent unless @flags.include?(flag) || @perm_flags.include?(:*) || @perm_flags.include?(flag) warning "flag not supported on destination: #{flag}" true end end supported_flags end # Fetches the latest flags from the server for the specified range of message # UIDs. def fetch_flags(flag_range) return unless imap_examine info "fetching latest message flags..." # Load the expected UIDs and their flags into a Hash for quicker lookups. expected_uids = {} @db_mailbox.messages_dataset.all do |db_message| expected_uids[db_message.uid] = db_message.flags end imap_uid_fetch(flag_range, "(UID FLAGS)", 16384) do |fetch_data| # Check the fields in the first response to ensure that everything we # asked for is there. check_response_fields(fetch_data.first, 'UID', 'FLAGS') unless fetch_data.empty? Larch.db.transaction do fetch_data.each do |data| uid = data.attr['UID'] flags = data.attr['FLAGS'] local_flags = expected_uids[uid] # If we haven't seen this message before, or if its flags have # changed, update the database. unless local_flags && local_flags == flags @db_mailbox.messages_dataset.filter(:uid => uid).update(:flags => flags.map{|f| f.to_s }.join(',')) end expected_uids.delete(uid) end end end # Any UIDs that are in the database but weren't in the response have been # deleted from the server, so we need to delete them from the database as # well. unless expected_uids.empty? debug "removing #{expected_uids.length} deleted messages from the database..." Larch.db.transaction do expected_uids.each_key do |uid| @db_mailbox.messages_dataset.filter(:uid => uid).destroy end end end expected_uids = nil fetch_data = nil end # Fetches the latest headers from the server for the specified range of # message UIDs. def fetch_headers(header_range, options = {}) return unless imap_examine options = { :progress_start => 0, :progress_total => 0 }.merge(options) fetched = 0 progress = 0 show_progress = options[:progress_total] - options[:progress_start] > FETCH_BLOCK_SIZE * 4 info "fetching message headers #{options[:progress_start]} through #{options[:progress_total]}..." last_good_uid = nil imap_uid_fetch(header_range, "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] RFC822.SIZE INTERNALDATE FLAGS)") do |fetch_data| # Check the fields in the first response to ensure that everything we # asked for is there. check_response_fields(fetch_data, 'UID', 'RFC822.SIZE', 'INTERNALDATE', 'FLAGS') Larch.db.transaction do fetch_data.each do |data| uid = data.attr['UID'] Database::Message.create( :mailbox_id => @db_mailbox.id, :guid => create_guid(data), :uid => uid, :message_id => parse_message_id(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']), :rfc822_size => data.attr['RFC822.SIZE'].to_i, :internaldate => Time.parse(data.attr['INTERNALDATE']).to_i, :flags => data.attr['FLAGS'] ) last_good_uid = uid end # Set this mailbox's uidnext value to the last known good UID that # was stored in the database, plus 1. This will allow Larch to # resume where the error occurred on the next attempt rather than # having to start over. @db_mailbox.update(:uidnext => last_good_uid + 1) end if show_progress fetched += fetch_data.length last_progress = progress progress = ((100 / (options[:progress_total] - options[:progress_start]).to_f) * fetched).round info "#{progress}% complete" if progress > last_progress end end end # Examines this mailbox. If _force_ is true, the mailbox will be examined even # if it is already selected (which isn't necessary unless you want to ensure # that it's in a read-only state). # # Returns +false+ if this mailbox cannot be examined, which may be the case if # the \Noselect attribute is set. def imap_examine(force = false) return false if @attr.include?(:Noselect) return true if @state == :examined || (!force && @state == :selected) @imap.safely do begin @imap.conn.close unless @state == :closed @state = :closed debug "examining mailbox" @imap.conn.examine(@name_utf7) refresh_flags @state = :examined rescue Net::IMAP::NoResponseError => e raise Error, "unable to examine mailbox: #{e.message}" end end return true end # Selects the mailbox if it is not already selected. If the mailbox does not # exist and _create_ is +true+, it will be created. Otherwise, a # Larch::IMAP::Error will be raised. # # Returns +false+ if this mailbox cannot be selected, which may be the case if # the \Noselect attribute is set. def imap_select(create = false) return false if @attr.include?(:Noselect) return true if @state == :selected @imap.safely do begin @imap.conn.close unless @state == :closed @state = :closed debug "selecting mailbox" @imap.conn.select(@name_utf7) refresh_flags @state = :selected rescue Net::IMAP::NoResponseError => e raise Error, "unable to select mailbox: #{e.message}" unless create info "creating mailbox: #{@name}" begin @imap.conn.create(@name_utf7) unless @imap.options[:dry_run] retry rescue => e raise Error, "unable to create mailbox: #{e.message}" end end end return true end # Sends an IMAP STATUS command and returns the status of the requested # attributes. Supported attributes include: # # - MESSAGES # - RECENT # - UIDNEXT # - UIDVALIDITY # - UNSEEN def imap_status(*attr) @imap.safely do begin debug "getting mailbox status" @imap.conn.status(@name_utf7, attr) rescue Net::IMAP::NoResponseError => e raise Error, "unable to get status of mailbox: #{e.message}" end end end # Fetches the specified _fields_ for the specified _set_ of UIDs, which can be # a number, Range, or Array of UIDs. # # If _set_ is a number, an Array containing a single Net::IMAP::FetchData # object will be yielded to the given block. # # If _set_ is a Range or Array of UIDs, Arrays of up to block_size # Net::IMAP::FetchData objects will be yielded until all requested messages # have been fetched. # # However, if _set_ is a Range with an end value of -1, a single Array # containing all requested messages will be yielded, since it's impossible to # divide an infinite range into finite blocks. def imap_uid_fetch(set, fields, block_size = FETCH_BLOCK_SIZE, &block) # :yields: fetch_data if set.is_a?(Numeric) || (set.is_a?(Range) && set.last < 0) data = @imap.safely do imap_examine @imap.conn.uid_fetch(set, fields) end yield data unless data.nil? return end blocks = [] pos = 0 if set.is_a?(Array) while pos < set.length blocks += set[pos, block_size] pos += block_size end elsif set.is_a?(Range) pos = set.min - 1 while pos < set.max blocks << ((pos + 1)..[set.max, pos += block_size].min) end end blocks.each do |block| data = @imap.safely do imap_examine begin data = @imap.conn.uid_fetch(block, fields) rescue Net::IMAP::NoResponseError => e raise unless e.message == 'Some messages could not be FETCHed (Failure)' # Workaround for stupid Gmail shenanigans. warning "Gmail error: '#{e.message}'; continuing anyway" end next data end yield data unless data.nil? end end # Parses a Message-Id header out of _str_ and returns it, or +nil+ if _str_ # doesn't contain a valid Message-Id header. def parse_message_id(str) return str =~ REGEX_MESSAGE_ID ? $1 : nil end # Refreshes the list of valid flags for this mailbox. def refresh_flags return unless @imap.conn.responses.has_key?('FLAGS') && @imap.conn.responses.has_key?('PERMANENTFLAGS') @flags = Array(@imap.conn.responses['FLAGS'].first) @perm_flags = Array(@imap.conn.responses['PERMANENTFLAGS'].first) end end end; end larch-1.1.2/lib/larch/errors.rb0000644000175000017500000000044012314171101015623 0ustar tinchotinchomodule Larch class Error < StandardError; end class Config class Error < Larch::Error; end end class IMAP class Error < Larch::Error; end class FatalError < Error; end class MailboxNotFoundError < Error; end class MessageNotFoundError < Error; end end end larch-1.1.2/lib/larch/db/0000755000175000017500000000000012314171101014351 5ustar tinchotincholarch-1.1.2/lib/larch/db/migrate/0000755000175000017500000000000012314171101016001 5ustar tinchotincholarch-1.1.2/lib/larch/db/migrate/002_add_timestamps.rb0000644000175000017500000000044112314171101021704 0ustar tinchotinchoclass AddTimestamps < Sequel::Migration def down alter_table :accounts do drop_column :created_at drop_column :updated_at end end def up alter_table :accounts do add_column :created_at, :integer add_column :updated_at, :integer end end end larch-1.1.2/lib/larch/db/migrate/001_create_schema.rb0000644000175000017500000000206112314171101021470 0ustar tinchotinchoclass CreateSchema < Sequel::Migration def down drop_table :accounts, :mailboxes, :messages end def up create_table :accounts do primary_key :id text :hostname, :null => false text :username, :null => false unique [:hostname, :username] end create_table :mailboxes do primary_key :id foreign_key :account_id, :table => :accounts text :name, :null => false text :delim, :null => false text :attr, :null => false, :default => '' integer :subscribed, :null => false, :default => 0 integer :uidvalidity integer :uidnext unique [:account_id, :name, :uidvalidity] end create_table :messages do primary_key :id foreign_key :mailbox_id, :table => :mailboxes integer :uid, :null => false text :guid, :null => false text :message_id integer :rfc822_size, :null => false integer :internaldate, :null => false text :flags, :null => false, :default => '' index :guid unique [:mailbox_id, :uid] end end end larch-1.1.2/lib/larch/db/message.rb0000644000175000017500000000060612314171101016324 0ustar tinchotinchomodule Larch; module Database class Message < Sequel::Model(:messages) def flags self[:flags].split(',').sort.map do |f| # Flags beginning with $ should be strings; all others should be symbols. f[0,1] == '$' ? f : f.to_sym end end def flags_str self[:flags] end def flags=(flags) self[:flags] = flags.map{|f| f.to_s }.join(',') end end end; end larch-1.1.2/lib/larch/db/mailbox.rb0000644000175000017500000000041212314171101016326 0ustar tinchotinchomodule Larch; module Database class Mailbox < Sequel::Model(:mailboxes) plugin :hook_class_methods one_to_many :messages, :class => Larch::Database::Message before_destroy do Larch::Database::Message.filter(:mailbox_id => id).destroy end end end; end larch-1.1.2/lib/larch/db/account.rb0000644000175000017500000000103212314171101016326 0ustar tinchotinchomodule Larch; module Database class Account < Sequel::Model(:accounts) plugin :hook_class_methods one_to_many :mailboxes, :class => Larch::Database::Mailbox before_create do now = Time.now.to_i self.created_at = now self.updated_at = now end before_destroy do Mailbox.filter(:account_id => id).destroy end before_save do now = Time.now.to_i self.created_at = now if self.created_at.nil? self.updated_at = now end def touch update(:updated_at => Time.now.to_i) end end end; end larch-1.1.2/lib/larch/config.rb0000644000175000017500000000717012314171101015563 0ustar tinchotinchomodule Larch class Config attr_reader :filename, :section DEFAULT = { 'all' => false, 'all-subscribed' => false, 'config' => File.join('~', '.larch', 'config.yaml'), 'database' => File.join('~', '.larch', 'larch.db'), 'delete' => false, 'dry-run' => false, 'exclude' => [], 'exclude-file' => nil, 'expunge' => false, 'from' => nil, 'from-folder' => nil, # actually INBOX; see validate() 'from-pass' => nil, 'from-user' => nil, 'max-retries' => 3, 'no-create-folder' => false, 'no-recurse' => false, 'ssl-certs' => nil, 'ssl-verify' => false, 'sync-flags' => false, 'to' => nil, 'to-folder' => nil, # actually INBOX; see validate() 'to-pass' => nil, 'to-user' => nil, 'verbosity' => 'info' }.freeze def initialize(section = 'default', filename = DEFAULT['config'], override = {}) @section = section.to_s @override = {} override.each do |k, v| opt = k.to_s.gsub('_', '-') @override[opt] = v if DEFAULT.has_key?(opt) && override["#{k}_given".to_sym] && v != DEFAULT[opt] end load_file(filename) validate end def fetch(name) (@cached || {})[name.to_s.gsub('_', '-')] || nil end alias [] fetch def load_file(filename) @filename = File.expand_path(filename) config = {} if File.exist?(@filename) begin config = YAML.load_file(@filename) rescue => e raise Larch::Config::Error, "config error in #{filename}: #{e}" end end @lookup = [@override, config[@section] || {}, config['default'] || {}, DEFAULT] cache_config end def method_missing(name) fetch(name) end # Validates the config and resolves conflicting settings. def validate ['from', 'to'].each do |s| raise Error, "'#{s}' must be a valid IMAP URI (e.g. imap://example.com)" unless fetch(s) =~ IMAP::REGEX_URI end unless Logger::LEVELS.has_key?(verbosity.to_sym) raise Error, "'verbosity' must be one of: #{Logger::LEVELS.keys.join(', ')}" end if exclude_file raise Error, "exclude file not found: #{exclude_file}" unless File.file?(exclude_file) raise Error, "exclude file cannot be read: #{exclude_file}" unless File.readable?(exclude_file) end if @cached['all'] || @cached['all-subscribed'] # A specific source folder wins over 'all' and 'all-subscribed' if @cached['from-folder'] @cached['all'] = false @cached['all-subscribed'] = false @cached['to-folder'] ||= @cached['from-folder'] elsif @cached['all'] && @cached['all-subscribed'] # 'all' wins over 'all-subscribed' @cached['all-subscribed'] = false end # 'no-recurse' is not compatible with 'all' and 'all-subscribed' raise Error, "'no-recurse' option cannot be used with 'all' or 'all-subscribed'" if @cached['no-recurse'] else @cached['from-folder'] ||= 'INBOX' @cached['to-folder'] ||= 'INBOX' end @cached['exclude'].flatten! end private # Merges configs such that those earlier in the lookup chain override those # later in the chain. def cache_config @cached = {} @lookup.reverse.each do |c| c.each {|k, v| @cached[k] = config_merge(@cached[k] || {}, v) } end end def config_merge(master, value) if value.is_a?(Hash) value.each {|k, v| master[k] = config_merge(master[k] || {}, v) } return master end value end end end larch-1.1.2/bin/0000755000175000017500000000000012314171101012675 5ustar tinchotincholarch-1.1.2/bin/larch0000755000175000017500000001354312314171101013722 0ustar tinchotincho#!/usr/bin/env ruby $0 = 'larch' # hide arguments from ps and top require 'rubygems' require 'highline/import' # optional dep: termios require 'trollop' require 'larch' module Larch # Parse command-line options. options = Trollop.options do version "Larch #{APP_VERSION}\n" << APP_COPYRIGHT banner <<-EOS Larch copies messages from one IMAP server to another. Awesomely. Usage: larch [config section] [options] larch --from --to [options] Server Options: EOS opt :from, "URI of the source IMAP server", :short => '-f', :type => :string opt :from_folder, "Source folder to copy from (default: INBOX)", :short => '-F', :default => Config::DEFAULT['from-folder'], :type => :string opt :from_pass, "Source server password (default: prompt)", :short => '-p', :type => :string opt :from_user, "Source server username (default: prompt)", :short => '-u', :type => :string opt :to, "URI of the destination IMAP server", :short => '-t', :type => :string opt :to_folder, "Destination folder to copy to (default: INBOX)", :short => '-T', :default => Config::DEFAULT['to-folder'], :type => :string opt :to_pass, "Destination server password (default: prompt)", :short => '-P', :type => :string opt :to_user, "Destination server username (default: prompt)", :short => '-U', :type => :string text "\nCopy Options:" opt :all, "Copy all folders recursively", :short => '-a' opt :all_subscribed, "Copy all subscribed folders recursively", :short => '-s' opt :delete, "Delete messages from the source after copying them, or if they already exist at the destination", :short => '-d' opt :exclude, "List of mailbox names/patterns that shouldn't be copied", :short => :none, :type => :strings, :multi => true opt :exclude_file, "Filename containing mailbox names/patterns that shouldn't be copied", :short => :none, :type => :string opt :expunge, "Expunge deleted messages from the source", :short => '-x' opt :no_recurse, "Don't copy subfolders recursively (cannot be used with --all or --all_subscribed)", :short => :none opt :sync_flags, "Sync message flags from the source to the destination for messages that already exist at the destination", :short => '-S' text "\nGeneral Options:" opt :config, "Specify a non-default config file to use", :short => '-c', :default => Config::DEFAULT['config'] opt :database, "Specify a non-default message database to use", :short => :none, :default => Config::DEFAULT['database'] opt :dry_run, "Don't actually make any changes", :short => '-n' opt :max_retries, "Maximum number of times to retry after a recoverable error", :short => :none, :default => Config::DEFAULT['max-retries'] opt :no_create_folder, "Don't create destination folders that don't already exist", :short => :none opt :ssl_certs, "Path to a trusted certificate bundle to use to verify server SSL certificates", :short => :none, :type => :string opt :ssl_verify, "Verify server SSL certificates", :short => :none opt :verbosity, "Output verbosity: debug, info, warn, error, or fatal", :short => '-V', :default => Config::DEFAULT['verbosity'] end if options[:config_given] Trollop.die :config, ": file not found: #{options[:config]}" unless File.exist?(options[:config]) end # Load config. begin config = Config.new(ARGV.shift || 'default', options[:config], options) rescue Config::Error => e abort "Config error: #{e}" end # Create URIs. uri_from = URI(config.from) uri_to = URI(config.to) # Use --from-folder and --to-folder unless folders were specified in the URIs. uri_from.path = uri_from.path.empty? ? '/' + CGI.escape(config.from_folder.gsub(/^\//, '')) : uri_from.path if config.from_folder uri_to.path = uri_to.path.empty? ? '/' + CGI.escape(config.to_folder.gsub(/^\//, '')) : uri_to.path if config.to_folder # --all and --all-subscribed options override folders if config.all || config.all_subscribed uri_from.path = '' end # Usernames and passwords specified as arguments override those in the URIs uri_from.user = CGI.escape(config.from_user) if config.from_user uri_from.password = CGI.escape(config.from_pass) if config.from_pass uri_to.user = CGI.escape(config.to_user) if config.to_user uri_to.password = CGI.escape(config.to_pass) if config.to_pass # If usernames/passwords aren't specified in either URIs or config, then prompt. uri_from.user ||= CGI.escape(ask("Source username (#{uri_from.host}): ")) uri_from.password ||= CGI.escape(ask("Source password (#{uri_from.host}): ") {|q| q.echo = false }) uri_to.user ||= CGI.escape(ask("Destination username (#{uri_to.host}): ")) uri_to.password ||= CGI.escape(ask("Destination password (#{uri_to.host}): ") {|q| q.echo = false }) # Go go go! init(config) imap_from = Larch::IMAP.new(uri_from, :dry_run => config[:dry_run], :log_label => '[<]', :max_retries => config[:max_retries], :ssl_certs => config[:ssl_certs] || nil, :ssl_verify => config[:ssl_verify] ) imap_to = Larch::IMAP.new(uri_to, :create_mailbox => !config[:no_create_folder] && !config[:dry_run], :dry_run => config[:dry_run], :log_label => '[>]', :max_retries => config[:max_retries], :ssl_certs => config[:ssl_certs] || nil, :ssl_verify => config[:ssl_verify] ) unless RUBY_PLATFORM =~ /mswin|mingw|bccwin|wince|java/ begin for sig in [:SIGINT, :SIGQUIT, :SIGTERM] trap(sig) { @log.fatal "Interrupted (#{sig})"; Kernel.exit } end rescue => e end end if config.all copy_all(imap_from, imap_to) elsif config.all_subscribed copy_all(imap_from, imap_to, true) else copy_folder(imap_from, imap_to) end end larch-1.1.2/README.rdoc0000644000175000017500000003365312314171101013745 0ustar tinchotincho= Larch Larch is a tool to copy messages from one IMAP server to another quickly and safely. It's smart enough not to copy messages that already exist on the destination and robust enough to deal with interruptions caused by flaky connections or misbehaving servers. Larch is particularly well-suited for copying email to, from, or between Gmail accounts. *Author*:: Ryan Grove (mailto:ryan@wonko.com) *Version*:: 1.1.2 (2013-01-24) *Copyright*:: Copyright (c) 2013 Ryan Grove. All rights reserved. *License*:: GPL 2.0 (http://opensource.org/licenses/gpl-2.0.php) *Website*:: http://github.com/rgrove/larch == Installation Latest stable release: gem install larch Latest development version: gem install larch --pre == Usage larch [config section] [options] larch --from --to [options] Server Options: --from, -f : URI of the source IMAP server --from-folder, -F : Source folder to copy from (default: INBOX) --from-pass, -p : Source server password (default: prompt) --from-user, -u : Source server username (default: prompt) --to, -t : URI of the destination IMAP server --to-folder, -T : Destination folder to copy to (default: INBOX) --to-pass, -P : Destination server password (default: prompt) --to-user, -U : Destination server username (default: prompt) Copy Options: --all, -a: Copy all folders recursively --all-subscribed, -s: Copy all subscribed folders recursively --delete, -d: Delete messages from the source after copying them, or if they already exist at the destination --exclude : List of mailbox names/patterns that shouldn't be copied --exclude-file : Filename containing mailbox names/patterns that shouldn't be copied --expunge, -x: Expunge deleted messages from the source --sync-flags, -S: Sync message flags from the source to the destination for messages that already exist at the destination General Options: --config, -c : Specify a non-default config file to use (default: ~/.larch/config.yaml) --database : Specify a non-default message database to use (default: ~/.larch/larch.db) --dry-run, -n: Don't actually make any changes --max-retries : Maximum number of times to retry after a recoverable error (default: 3) --no-create-folder: Don't create destination folders that don't already exist --ssl-certs : Path to a trusted certificate bundle to use to verify server SSL certificates --ssl-verify: Verify server SSL certificates --verbosity, -V : Output verbosity: debug, info, warn, error, or fatal (default: info) --version, -v: Print version and exit --help, -h: Show this message == Usage Examples Larch is run from the command line. The following examples demonstrate how to run Larch using only command line arguments, but you may also place these options in a config file and run Larch without any arguments if you prefer. See the "Configuration" section below for more details. For an overview of all available options, run: larch -h At a minimum, you must specify a source server and a destination server in the form of IMAP URIs: larch --from imap://mail.example.com --to imap://imap.gmail.com Larch will prompt you for the necessary usernames and passwords, then sync the contents of the source's +INBOX+ folder to the destination's INBOX folder. To connect using SSL, specify a URI beginning with imaps://: larch --from imaps://mail.example.com --to imaps://imap.gmail.com If you'd like to sync a specific folder other than +INBOX+, specify the source and destination folders using --from-folder and --to-folder. Folder names containing spaces must be enclosed in quotes: larch --from imaps://mail.example.com --to imaps://imap.gmail.com \ --from-folder 'Sent Mail' --to-folder 'Sent Mail' To sync all folders, use the --all option (or --all-subscribed if you only want to sync subscribed folders): larch --from imaps://mail.example.com --to imaps://imap.gmail.com --all By default Larch will create folders on the destination server if they don't already exist. To prevent this, add the --no-create-folder option: larch --from imaps://mail.example.com --to imaps://imap.gmail.com --all \ --no-create-folder You can prevent Larch from syncing one or more folders by using the --exclude option, which accepts multiple arguments: larch --from imaps://mail.example.com --to imaps://imap.gmail.com --all \ --exclude Spam Trash Drafts "[Gmail]/*" If your exclusion list is long or complex, create a text file with one exclusion pattern per line and tell Larch to load it with the --exclude-file option: larch --from imaps://mail.example.com --to imaps://imap.gmail.com --all \ --exclude-file exclude.txt The wildcard characters * and ? are supported in exclusion lists. You may also use a regular expression by enclosing a pattern in forward slashes, so the previous example could be achieved with the pattern /(Spam|Trash|Drafts|\[Gmail\]\/.*)/ == Configuration While it's possible to control Larch entirely from the command line, this can be inconvenient if you need to specify a lot of options or if you run Larch frequently and can't always remember which options to use. Using a configuration file can simplify things. By default, Larch looks for a config file at ~/.larch/config.yaml and uses it if found. You may specify a custom config file using the --config command line option. The Larch configuration file is a simple YAML[http://yaml.org/] file that may contain multiple sections, each with a different set of options, as well as a special +default+ section. The options in the +default+ section will be used unless they're overridden either in another config section or on the command line. === Example Here's a sample Larch config file: default: all-subscribed: true # Copy all subscribed folders by default # Copy mail from Gmail to my server, excluding stuff I don't want. gmail to my server: from: imaps://imap.gmail.com from-user: example from-pass: secret to: imaps://mail.example.com to-user: example to-pass: secret exclude: - "[Gmail]/Sent Mail" - "[Gmail]/Spam" - "[Gmail]/Trash" # Copy mail from my INBOX to Gmail's INBOX my inbox to gmail inbox: all-subscribed: false from: imaps://mail.example.com from-folder: INBOX from-user: example from-pass: secret to: imaps://imap.gmail.com to-folder: INBOX to-user: example to-pass: secret This file contains three sections. The options from +default+ will be used in all other sections as well unless they're overridden. To specify which config section you want Larch to use, just pass its name on the command line (use quotes if the name contains spaces): larch 'gmail to my server' If you specify additional command line options, they'll override options in the config file: larch 'gmail to my server' --from-user anotheruser Running Larch with no command line arguments will cause the +default+ section to be used. With the example above, this will result in an error since the +default+ section doesn't contain the required +from+ and +to+ options, but if you only need to use Larch with a single configuration, you could use the +default+ section for everything and save yourself some typing on the command line. == Server Compatibility Larch should work well with any server that properly supports IMAP4rev1[http://tools.ietf.org/html/rfc3501], and does its best to get along with servers that have buggy, unreliable, or incomplete IMAP implementations. Larch has been tested on and is known to work well with the following IMAP servers: * Dovecot * Gmail * Microsoft Exchange 2003 The following servers are known to work, but with caveats: * Yahoo! Mail The following servers do not work well with Larch: * BlitzMail - Buggy server implementation; fails to properly quote or escape some IMAP responses, which can cause Net::IMAP to hang waiting for a terminating character that will never arrive. === Gmail Quirks Gmail's IMAP implementation is quirky. Larch does its best to work around these quirks whenever possible, but here are a few things to watch out for: ==== "Some messages could not be FETCHed" error This error indicates that a message on Gmail is corrupt, and Gmail itself is unable to read it. The message will continue to show up in the mailbox, but all attempts to access it via IMAP, POP, or the Gmail web interface will result in errors. Larch will try to skip these messages and continue processing others if possible. It's not clear how this corruption occurs or exactly what kind of corruption causes these errors, although in every case I'm aware of, the corrupt message has originated outside of Gmail (Gmail itself does not corrupt the message). There is currently no known solution for this problem apart from deleting the corrupted messages. ==== Folder names cannot contain leading or trailing whitespace Most IMAP servers allow folder names to contain leading and trailing whitespace, such as " folder ". Gmail does not. When copying folders to Gmail, Larch will automatically remove leading and trailing whitespace in folder names to prevent errors. === Yahoo! Mail Quirks Yahoo! doesn't officially support IMAP access for general usage, but Larch is able to connect to imap.mail.yahoo.com and imap-ssl.mail.yahoo.com by using a fairly well-known trick. That said, as with anything tricky, there are caveats. ==== No hierarchical folders Similar to Gmail, Yahoo! Mail doesn't allow hierarchical (nested) folders. If you try to copy a folder hierarchy to Yahoo!, it will work, but you'll end up with a set of folders named "folder" and "folder.subfolder" rather than seeing "subfolder" as an actual subfolder of "folder". ==== No custom flags Yahoo! Mail IMAP doesn't support custom message flags, such as the tags and junk/not junk flags used by Thunderbird. When transferring messages with custom flags to a Yahoo! Mail IMAP account, the custom flags will be lost. ==== Here there be dragons Larch's support for Yahoo! Mail is very new and very lightly tested. Given its newness and the fact that Yahoo!'s IMAP gateway isn't official, there are likely to be other quirks we're not yet aware of. There's also no guarantee that Yahoo! won't shut down its IMAP gateway, deprecate the trick Larch uses to connect, or just outright block Larch. Use at your own risk. == Known Issues * Larch uses Ruby's Net::IMAP standard library for all IMAP operations. While Net::IMAP is generally a very solid library, it contains a bug that can cause a deadlock to occur if a connection drops unexpectedly (either due to network issues or because the server closed the connection without warning) when the server has already begun sending a response and Net::IMAP is waiting to receive more data. If this happens, Net::IMAP will continue waiting forever without passing control back to Larch, and you will need to manually kill and restart Larch. Net::IMAP in Ruby 1.8 has also been known to hang when it can't parse a server response, either because the response itself is malformed or because of a bug in Net::IMAP's parser. This is rare, but it happens. Unfortunately there's nothing Larch can do about this. * The Ruby package on Debian, Ubuntu, and some other Debian-based Linux distributions doesn't include the OpenSSL standard library. If you see an error like uninitialized constant Larch::IMAP::OpenSSL (NameError) when running Larch, you may need to install the libopenssl-ruby package. Please feel free to complain to the maintainer of your distribution's Ruby packages. == Support The Larch mailing list is the best place for questions, comments, and discussion about Larch. You can join the list or view the archives at http://groups.google.com/group/larch First-time senders to the list are moderated to prevent spam, so there may be a delay before your first message shows up. == Contributors Larch was created and is maintained by Ryan Grove . The following lovely people have also contributed to Larch: * Torey Heinz * Edgardo Hames * Andrew Hobson * Justin Mazzi == Credit The Larch::IMAP class borrows heavily from Sup[http://sup.rubyforge.org] by William Morgan, the source code of which should be required reading if you're doing anything with IMAP in Ruby. Larch uses the excellent Trollop[http://trollop.rubyforge.org] command-line option parser (also by William Morgan) and the HighLine[http://highline.rubyforge.org] command-line IO library (by James Edward Gray II). == License Copyright (c) 2013 Ryan Grove Licensed under the GNU General Public License version 2.0. This program is free software; you can redistribute it and/or modify it under the terms of version 2.0 of the GNU General Public License as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, visit http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt or write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. larch-1.1.2/LICENSE0000644000175000017500000003542212314171101013140 0ustar tinchotincho GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS larch-1.1.2/HISTORY0000644000175000017500000001000612314171101013206 0ustar tinchotinchoLarch History ================================================================================ Version 1.1.2 (2013-01-24) * Fixed an issue in which a folder containing only one message would not be copied. * Switched to the sqlite3 gem instead of amalgalite, since the latter fails to compile against Ruby 1.9.3. This means you'll need to have SQLite available on your system, since it's no longer bundled. Version 1.1.1 (2011-06-26) * Relaxed dependency versions. Version 1.1.0 (2011-01-22) * Mailbox and message state information is now stored in a local SQLite database, which allows Larch to resync and resume interrupted syncs much more quickly without having to rescan all messages. As a result, SQLite 3 is now a dependency. * Larch now loads config options from ~/.larch/config.yaml if it exists, or from the file specified by the --config command-line option. This file may contain multiple sections. If a section name is specified via the command-line, Larch will use the options in that section for the session; otherwise it will use the options in the "default" section. See the README for more details. * Added experimental support for Yahoo! Mail IMAP when connecting to imap.mail.yahoo.com or imap-ssl.mail.yahoo.com. See the README for caveats and known issues. * Folders are now copied recursively by default. Use the --no-recurse option for the old behavior. * Progress information is now displayed regularly while scanning large mailboxes. * Added --delete option to delete messages from the source after copying them to the destination, or if they already exist at the destination. * Added --expunge option to expunge deleted messages from the source. * Added --sync-flags option to synchronize message flags (like Seen, Flagged, etc.) from the source server to the destination server for messages that already exist on the destination. * Added short versions of common command-line options. * The --fast-scan option has been removed. * The --to-folder option can now be used in conjunction with --all or --all-subscribed to copy messages from multiple source folders to a single destination folder. * More concise log messages to reduce visual clutter in the log. * Monkeypatched Net::IMAP to fix broken response handling with certain server responses, particularly when changing mailboxes on a Dovecot 1.2+ server. * Fixed encoding issues when creating mailboxes and getting mailbox lists. * Fixed incorrect case-sensitive treatment of the 'INBOX' folder name. * Fixed a bug in which Larch would try to copy flags that weren't supported on the destination server. * Fixed an issue when trying to copy a folder with leading or trailing whitespace in the name to Gmail, since Gmail doesn't allow whitespace around folder names. * Fixed an issue with bogus Exchange STATUS responses containing trailing spaces. Version 1.0.2 (2009-08-05) * Fixed a bug that caused Larch to try to set the read-only \Recent flag on the destination server. Version 1.0.1 (2009-05-10) * Ruby 1.9.1 support. * Much more robust handling of unexpected server disconnects and dropped connections. * Added --all option to copy all folders recursively. * Added --all-subscribed option to copy all subscribed folders recursively. * Added --dry-run option to simulate changes without actually making them. * Added --exclude and --exclude-file options to specify folders that should not be copied. * Added --ssl-certs option to specify a bundle of trusted SSL certificates. * Added --ssl-verify option to verify server SSL certificates. * Added a new "insane" logging level, which will output all IMAP commands and responses to STDERR. * Fixed excessive post-scan processing times for very large mailboxes. * Fixed potential scan problems with very large mailboxes on certain servers. * POSIX signals are no longer trapped on platforms that aren't likely to support them. Version 1.0.0 (2009-03-17) * First release.