segfault-larch 1.0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/HISTORY +44 -0
 - data/LICENSE +280 -0
 - data/README.rdoc +273 -0
 - data/bin/larch +123 -0
 - data/lib/larch.rb +254 -0
 - data/lib/larch/config.rb +105 -0
 - data/lib/larch/db/account.rb +12 -0
 - data/lib/larch/db/mailbox.rb +12 -0
 - data/lib/larch/db/message.rb +6 -0
 - data/lib/larch/db/migrate/001_create_schema.rb +42 -0
 - data/lib/larch/errors.rb +14 -0
 - data/lib/larch/imap.rb +343 -0
 - data/lib/larch/imap/mailbox.rb +505 -0
 - data/lib/larch/logger.rb +50 -0
 - data/lib/larch/version.rb +9 -0
 - metadata +106 -0
 
    
        data/bin/larch
    ADDED
    
    | 
         @@ -0,0 +1,123 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            #!/usr/bin/env ruby
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            require 'rubygems'
         
     | 
| 
      
 4 
     | 
    
         
            +
            require 'highline/import' # optional dep: termios
         
     | 
| 
      
 5 
     | 
    
         
            +
            require 'trollop'
         
     | 
| 
      
 6 
     | 
    
         
            +
             
     | 
| 
      
 7 
     | 
    
         
            +
            require 'larch'
         
     | 
| 
      
 8 
     | 
    
         
            +
             
     | 
| 
      
 9 
     | 
    
         
            +
            module Larch
         
     | 
| 
      
 10 
     | 
    
         
            +
             
     | 
| 
      
 11 
     | 
    
         
            +
              # Parse command-line options.
         
     | 
| 
      
 12 
     | 
    
         
            +
              options = Trollop.options do
         
     | 
| 
      
 13 
     | 
    
         
            +
                version "Larch #{APP_VERSION}\n" << APP_COPYRIGHT
         
     | 
| 
      
 14 
     | 
    
         
            +
                banner <<-EOS
         
     | 
| 
      
 15 
     | 
    
         
            +
            Larch syncs messages from one IMAP server to another. Awesomely.
         
     | 
| 
      
 16 
     | 
    
         
            +
             
     | 
| 
      
 17 
     | 
    
         
            +
            Usage:
         
     | 
| 
      
 18 
     | 
    
         
            +
              larch [config section] [options]
         
     | 
| 
      
 19 
     | 
    
         
            +
              larch --from <uri> --to <uri> [options]
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
            Server Options:
         
     | 
| 
      
 22 
     | 
    
         
            +
            EOS
         
     | 
| 
      
 23 
     | 
    
         
            +
                opt :from,        "URI of the source IMAP server.", :short => '-f', :type => :string
         
     | 
| 
      
 24 
     | 
    
         
            +
                opt :from_folder, "Source folder to copy from", :short => '-F', :default => Config::DEFAULT['from-folder']
         
     | 
| 
      
 25 
     | 
    
         
            +
                opt :from_pass,   "Source server password (default: prompt)", :short => '-p', :type => :string
         
     | 
| 
      
 26 
     | 
    
         
            +
                opt :from_user,   "Source server username (default: prompt)", :short => '-u', :type => :string
         
     | 
| 
      
 27 
     | 
    
         
            +
                opt :to,          "URI of the destination IMAP server.", :short => '-t', :type => :string
         
     | 
| 
      
 28 
     | 
    
         
            +
                opt :to_folder,   "Destination folder to copy to", :short => '-T', :default => Config::DEFAULT['to-folder']
         
     | 
| 
      
 29 
     | 
    
         
            +
                opt :to_pass,     "Destination server password (default: prompt)", :short => '-P', :type => :string
         
     | 
| 
      
 30 
     | 
    
         
            +
                opt :to_user,     "Destination server username (default: prompt)", :short => '-U', :type => :string
         
     | 
| 
      
 31 
     | 
    
         
            +
             
     | 
| 
      
 32 
     | 
    
         
            +
                text "\nSync Options:"
         
     | 
| 
      
 33 
     | 
    
         
            +
                opt :all,              "Copy all folders recursively", :short => '-a'
         
     | 
| 
      
 34 
     | 
    
         
            +
                opt :all_subscribed,   "Copy all subscribed folders recursively", :short => '-s'
         
     | 
| 
      
 35 
     | 
    
         
            +
                opt :exclude,          "List of mailbox names/patterns that shouldn't be copied", :short => :none, :type => :strings, :multi => true
         
     | 
| 
      
 36 
     | 
    
         
            +
                opt :exclude_file,     "Filename containing mailbox names/patterns that shouldn't be copied", :short => :none, :type => :string
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
                text "\nGeneral Options:"
         
     | 
| 
      
 39 
     | 
    
         
            +
                opt :config,           "Specify a non-default config file to use", :short => '-c', :default => Config::DEFAULT['config']
         
     | 
| 
      
 40 
     | 
    
         
            +
                opt :database,         "Specify a non-default message database to use", :short => :none, :default => Config::DEFAULT['database']
         
     | 
| 
      
 41 
     | 
    
         
            +
                opt :dry_run,          "Don't actually make any changes", :short => '-n'
         
     | 
| 
      
 42 
     | 
    
         
            +
                opt :max_retries,      "Maximum number of times to retry after a recoverable error", :short => :none, :default => Config::DEFAULT['max-retries']
         
     | 
| 
      
 43 
     | 
    
         
            +
                opt :no_create_folder, "Don't create destination folders that don't already exist", :short => :none
         
     | 
| 
      
 44 
     | 
    
         
            +
                opt :ssl_certs,        "Path to a trusted certificate bundle to use to verify server SSL certificates", :short => :none, :type => :string
         
     | 
| 
      
 45 
     | 
    
         
            +
                opt :ssl_verify,       "Verify server SSL certificates", :short => :none
         
     | 
| 
      
 46 
     | 
    
         
            +
                opt :verbosity,        "Output verbosity: debug, info, warn, error, or fatal", :short => '-V', :default => Config::DEFAULT['verbosity']
         
     | 
| 
      
 47 
     | 
    
         
            +
              end
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
      
 49 
     | 
    
         
            +
              # Load config.
         
     | 
| 
      
 50 
     | 
    
         
            +
              config = Config.new(ARGV.shift || 'default', options[:config], options)
         
     | 
| 
      
 51 
     | 
    
         
            +
              
         
     | 
| 
      
 52 
     | 
    
         
            +
              if options[:config_given]
         
     | 
| 
      
 53 
     | 
    
         
            +
                Trollop.die :config, ": file not found: #{options[:config]}" unless File.exist?(options[:config])
         
     | 
| 
      
 54 
     | 
    
         
            +
              end
         
     | 
| 
      
 55 
     | 
    
         
            +
             
     | 
| 
      
 56 
     | 
    
         
            +
              # Validate config.
         
     | 
| 
      
 57 
     | 
    
         
            +
              begin
         
     | 
| 
      
 58 
     | 
    
         
            +
                config.validate
         
     | 
| 
      
 59 
     | 
    
         
            +
              rescue Config::Error => e
         
     | 
| 
      
 60 
     | 
    
         
            +
                abort "Config error: #{e}"
         
     | 
| 
      
 61 
     | 
    
         
            +
              end
         
     | 
| 
      
 62 
     | 
    
         
            +
             
     | 
| 
      
 63 
     | 
    
         
            +
              # Create URIs.
         
     | 
| 
      
 64 
     | 
    
         
            +
              uri_from = URI(config.from)
         
     | 
| 
      
 65 
     | 
    
         
            +
              uri_to   = URI(config.to)
         
     | 
| 
      
 66 
     | 
    
         
            +
             
     | 
| 
      
 67 
     | 
    
         
            +
              # Use --from-folder and --to-folder unless folders were specified in the URIs.
         
     | 
| 
      
 68 
     | 
    
         
            +
              uri_from.path ||= '/' + CGI.escape(config.from_folder.gsub(/^\//, ''))
         
     | 
| 
      
 69 
     | 
    
         
            +
              uri_to.path   ||= '/' + CGI.escape(config.to_folder.gsub(/^\//, ''))
         
     | 
| 
      
 70 
     | 
    
         
            +
             
     | 
| 
      
 71 
     | 
    
         
            +
              # --all and --all-subscribed options override folders
         
     | 
| 
      
 72 
     | 
    
         
            +
              if config.all || config.all_subscribed
         
     | 
| 
      
 73 
     | 
    
         
            +
                uri_from.path = ''
         
     | 
| 
      
 74 
     | 
    
         
            +
                uri_to.path   = ''
         
     | 
| 
      
 75 
     | 
    
         
            +
              end
         
     | 
| 
      
 76 
     | 
    
         
            +
             
     | 
| 
      
 77 
     | 
    
         
            +
              # Usernames and passwords specified as arguments override those in the URIs
         
     | 
| 
      
 78 
     | 
    
         
            +
              uri_from.user     = CGI.escape(config.from_user) if config.from_user
         
     | 
| 
      
 79 
     | 
    
         
            +
              uri_from.password = CGI.escape(config.from_pass) if config.from_pass
         
     | 
| 
      
 80 
     | 
    
         
            +
              uri_to.user       = CGI.escape(config.to_user) if config.to_user
         
     | 
| 
      
 81 
     | 
    
         
            +
              uri_to.password   = CGI.escape(config.to_pass) if config.to_pass
         
     | 
| 
      
 82 
     | 
    
         
            +
             
     | 
| 
      
 83 
     | 
    
         
            +
              # If usernames/passwords aren't specified in either URIs or config, then prompt.
         
     | 
| 
      
 84 
     | 
    
         
            +
              uri_from.user     ||= CGI.escape(ask("Source username (#{uri_from.host}): "))
         
     | 
| 
      
 85 
     | 
    
         
            +
              uri_from.password ||= CGI.escape(ask("Source password (#{uri_from.host}): ") {|q| q.echo = false })
         
     | 
| 
      
 86 
     | 
    
         
            +
              uri_to.user       ||= CGI.escape(ask("Destination username (#{uri_to.host}): "))
         
     | 
| 
      
 87 
     | 
    
         
            +
              uri_to.password   ||= CGI.escape(ask("Destination password (#{uri_to.host}): ") {|q| q.echo = false })
         
     | 
| 
      
 88 
     | 
    
         
            +
             
     | 
| 
      
 89 
     | 
    
         
            +
              # Go go go!
         
     | 
| 
      
 90 
     | 
    
         
            +
              init(config)
         
     | 
| 
      
 91 
     | 
    
         
            +
             
     | 
| 
      
 92 
     | 
    
         
            +
              imap_from = Larch::IMAP.new(uri_from,
         
     | 
| 
      
 93 
     | 
    
         
            +
                  :dry_run     => config[:dry_run],
         
     | 
| 
      
 94 
     | 
    
         
            +
                  :max_retries => config[:max_retries],
         
     | 
| 
      
 95 
     | 
    
         
            +
                  :ssl_certs   => config[:ssl_certs] || nil,
         
     | 
| 
      
 96 
     | 
    
         
            +
                  :ssl_verify  => config[:ssl_verify]
         
     | 
| 
      
 97 
     | 
    
         
            +
              )
         
     | 
| 
      
 98 
     | 
    
         
            +
             
     | 
| 
      
 99 
     | 
    
         
            +
              imap_to = Larch::IMAP.new(uri_to,
         
     | 
| 
      
 100 
     | 
    
         
            +
                  :create_mailbox => !config[:no_create_folder] && !config[:dry_run],
         
     | 
| 
      
 101 
     | 
    
         
            +
                  :dry_run        => config[:dry_run],
         
     | 
| 
      
 102 
     | 
    
         
            +
                  :max_retries    => config[:max_retries],
         
     | 
| 
      
 103 
     | 
    
         
            +
                  :ssl_certs      => config[:ssl_certs] || nil,
         
     | 
| 
      
 104 
     | 
    
         
            +
                  :ssl_verify     => config[:ssl_verify]
         
     | 
| 
      
 105 
     | 
    
         
            +
              )
         
     | 
| 
      
 106 
     | 
    
         
            +
             
     | 
| 
      
 107 
     | 
    
         
            +
              unless RUBY_PLATFORM =~ /mswin|mingw|bccwin|wince|java/
         
     | 
| 
      
 108 
     | 
    
         
            +
                begin
         
     | 
| 
      
 109 
     | 
    
         
            +
                  for sig in [:SIGINT, :SIGQUIT, :SIGTERM]
         
     | 
| 
      
 110 
     | 
    
         
            +
                    trap(sig) { @log.fatal "Interrupted (#{sig})"; Kernel.exit }
         
     | 
| 
      
 111 
     | 
    
         
            +
                  end
         
     | 
| 
      
 112 
     | 
    
         
            +
                rescue => e
         
     | 
| 
      
 113 
     | 
    
         
            +
                end
         
     | 
| 
      
 114 
     | 
    
         
            +
              end
         
     | 
| 
      
 115 
     | 
    
         
            +
             
     | 
| 
      
 116 
     | 
    
         
            +
              if config.all
         
     | 
| 
      
 117 
     | 
    
         
            +
                copy_all(imap_from, imap_to)
         
     | 
| 
      
 118 
     | 
    
         
            +
              elsif config.all_subscribed
         
     | 
| 
      
 119 
     | 
    
         
            +
                copy_all(imap_from, imap_to, true)
         
     | 
| 
      
 120 
     | 
    
         
            +
              else
         
     | 
| 
      
 121 
     | 
    
         
            +
                copy_folder(imap_from, imap_to)
         
     | 
| 
      
 122 
     | 
    
         
            +
              end
         
     | 
| 
      
 123 
     | 
    
         
            +
            end
         
     | 
    
        data/lib/larch.rb
    ADDED
    
    | 
         @@ -0,0 +1,254 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # Prepend this file's directory to the include path if it's not there already.
         
     | 
| 
      
 2 
     | 
    
         
            +
            $:.unshift(File.dirname(File.expand_path(__FILE__)))
         
     | 
| 
      
 3 
     | 
    
         
            +
            $:.uniq!
         
     | 
| 
      
 4 
     | 
    
         
            +
             
     | 
| 
      
 5 
     | 
    
         
            +
            require 'cgi'
         
     | 
| 
      
 6 
     | 
    
         
            +
            require 'digest/md5'
         
     | 
| 
      
 7 
     | 
    
         
            +
            require 'fileutils'
         
     | 
| 
      
 8 
     | 
    
         
            +
            require 'net/imap'
         
     | 
| 
      
 9 
     | 
    
         
            +
            require 'time'
         
     | 
| 
      
 10 
     | 
    
         
            +
            require 'uri'
         
     | 
| 
      
 11 
     | 
    
         
            +
            require 'yaml'
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
            require 'sequel'
         
     | 
| 
      
 14 
     | 
    
         
            +
            require 'sequel/extensions/migration'
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
            require 'larch/config'
         
     | 
| 
      
 17 
     | 
    
         
            +
            require 'larch/errors'
         
     | 
| 
      
 18 
     | 
    
         
            +
            require 'larch/imap'
         
     | 
| 
      
 19 
     | 
    
         
            +
            require 'larch/imap/mailbox'
         
     | 
| 
      
 20 
     | 
    
         
            +
            require 'larch/logger'
         
     | 
| 
      
 21 
     | 
    
         
            +
            require 'larch/version'
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
            module Larch
         
     | 
| 
      
 24 
     | 
    
         
            +
             
     | 
| 
      
 25 
     | 
    
         
            +
              class << self
         
     | 
| 
      
 26 
     | 
    
         
            +
                attr_reader :config, :db, :log, :exclude
         
     | 
| 
      
 27 
     | 
    
         
            +
             
     | 
| 
      
 28 
     | 
    
         
            +
                EXCLUDE_COMMENT = /#.*$/
         
     | 
| 
      
 29 
     | 
    
         
            +
                EXCLUDE_REGEX   = /^\s*\/(.*)\/\s*/
         
     | 
| 
      
 30 
     | 
    
         
            +
                GLOB_PATTERNS   = {'*' => '.*', '?' => '.'}
         
     | 
| 
      
 31 
     | 
    
         
            +
                LIB_DIR         = File.join(File.dirname(File.expand_path(__FILE__)), 'larch')
         
     | 
| 
      
 32 
     | 
    
         
            +
             
     | 
| 
      
 33 
     | 
    
         
            +
                def init(config)
         
     | 
| 
      
 34 
     | 
    
         
            +
                  raise ArgumentError, "config must be a Larch::Config instance" unless config.is_a?(Config)
         
     | 
| 
      
 35 
     | 
    
         
            +
             
     | 
| 
      
 36 
     | 
    
         
            +
                  @config = config
         
     | 
| 
      
 37 
     | 
    
         
            +
                  @log    = Logger.new(@config[:verbosity])
         
     | 
| 
      
 38 
     | 
    
         
            +
                  @db     = open_db(@config[:database])
         
     | 
| 
      
 39 
     | 
    
         
            +
             
     | 
| 
      
 40 
     | 
    
         
            +
                  @exclude = @config[:exclude].map do |e|
         
     | 
| 
      
 41 
     | 
    
         
            +
                    if e =~ EXCLUDE_REGEX
         
     | 
| 
      
 42 
     | 
    
         
            +
                      Regexp.new($1, Regexp::IGNORECASE)
         
     | 
| 
      
 43 
     | 
    
         
            +
                    else
         
     | 
| 
      
 44 
     | 
    
         
            +
                      glob_to_regex(e.strip)
         
     | 
| 
      
 45 
     | 
    
         
            +
                    end
         
     | 
| 
      
 46 
     | 
    
         
            +
                  end
         
     | 
| 
      
 47 
     | 
    
         
            +
             
     | 
| 
      
 48 
     | 
    
         
            +
                  load_exclude_file(@config[:exclude_file]) if @config[:exclude_file]
         
     | 
| 
      
 49 
     | 
    
         
            +
             
     | 
| 
      
 50 
     | 
    
         
            +
                  Net::IMAP.debug = true if @log.level == :insane
         
     | 
| 
      
 51 
     | 
    
         
            +
             
     | 
| 
      
 52 
     | 
    
         
            +
                  # Stats
         
     | 
| 
      
 53 
     | 
    
         
            +
                  @copied = 0
         
     | 
| 
      
 54 
     | 
    
         
            +
                  @failed = 0
         
     | 
| 
      
 55 
     | 
    
         
            +
                  @total  = 0
         
     | 
| 
      
 56 
     | 
    
         
            +
                end
         
     | 
| 
      
 57 
     | 
    
         
            +
             
     | 
| 
      
 58 
     | 
    
         
            +
                # Recursively copies all messages in all folders from the source to the
         
     | 
| 
      
 59 
     | 
    
         
            +
                # destination.
         
     | 
| 
      
 60 
     | 
    
         
            +
                def copy_all(imap_from, imap_to, subscribed_only = false)
         
     | 
| 
      
 61 
     | 
    
         
            +
                  raise ArgumentError, "imap_from must be a Larch::IMAP instance" unless imap_from.is_a?(IMAP)
         
     | 
| 
      
 62 
     | 
    
         
            +
                  raise ArgumentError, "imap_to must be a Larch::IMAP instance" unless imap_to.is_a?(IMAP)
         
     | 
| 
      
 63 
     | 
    
         
            +
             
     | 
| 
      
 64 
     | 
    
         
            +
                  @copied = 0
         
     | 
| 
      
 65 
     | 
    
         
            +
                  @failed = 0
         
     | 
| 
      
 66 
     | 
    
         
            +
                  @total  = 0
         
     | 
| 
      
 67 
     | 
    
         
            +
             
     | 
| 
      
 68 
     | 
    
         
            +
                  imap_from.each_mailbox do |mailbox_from|
         
     | 
| 
      
 69 
     | 
    
         
            +
                    next if excluded?(mailbox_from.name)
         
     | 
| 
      
 70 
     | 
    
         
            +
                    next if subscribed_only && !mailbox_from.subscribed?
         
     | 
| 
      
 71 
     | 
    
         
            +
             
     | 
| 
      
 72 
     | 
    
         
            +
                    mailbox_to = imap_to.mailbox(mailbox_from.name, mailbox_from.delim)
         
     | 
| 
      
 73 
     | 
    
         
            +
                    mailbox_to.subscribe if mailbox_from.subscribed?
         
     | 
| 
      
 74 
     | 
    
         
            +
             
     | 
| 
      
 75 
     | 
    
         
            +
                    copy_messages(mailbox_from, mailbox_to)
         
     | 
| 
      
 76 
     | 
    
         
            +
                  end
         
     | 
| 
      
 77 
     | 
    
         
            +
             
     | 
| 
      
 78 
     | 
    
         
            +
                rescue => e
         
     | 
| 
      
 79 
     | 
    
         
            +
                  @log.fatal e.message
         
     | 
| 
      
 80 
     | 
    
         
            +
             
     | 
| 
      
 81 
     | 
    
         
            +
                ensure
         
     | 
| 
      
 82 
     | 
    
         
            +
                  summary
         
     | 
| 
      
 83 
     | 
    
         
            +
                end
         
     | 
| 
      
 84 
     | 
    
         
            +
             
     | 
| 
      
 85 
     | 
    
         
            +
                # Copies the messages in a single IMAP folder and all its subfolders
         
     | 
| 
      
 86 
     | 
    
         
            +
                # (recursively) from the source to the destination.
         
     | 
| 
      
 87 
     | 
    
         
            +
                def copy_folder(imap_from, imap_to)
         
     | 
| 
      
 88 
     | 
    
         
            +
                  raise ArgumentError, "imap_from must be a Larch::IMAP instance" unless imap_from.is_a?(IMAP)
         
     | 
| 
      
 89 
     | 
    
         
            +
                  raise ArgumentError, "imap_to must be a Larch::IMAP instance" unless imap_to.is_a?(IMAP)
         
     | 
| 
      
 90 
     | 
    
         
            +
             
     | 
| 
      
 91 
     | 
    
         
            +
                  @copied = 0
         
     | 
| 
      
 92 
     | 
    
         
            +
                  @failed = 0
         
     | 
| 
      
 93 
     | 
    
         
            +
                  @total  = 0
         
     | 
| 
      
 94 
     | 
    
         
            +
             
     | 
| 
      
 95 
     | 
    
         
            +
                  mailbox_from = imap_from.mailbox(imap_from.uri_mailbox || 'INBOX')
         
     | 
| 
      
 96 
     | 
    
         
            +
                  mailbox_to   = imap_to.mailbox(imap_to.uri_mailbox || 'INBOX')
         
     | 
| 
      
 97 
     | 
    
         
            +
             
     | 
| 
      
 98 
     | 
    
         
            +
                  copy_mailbox(mailbox_from, mailbox_to)
         
     | 
| 
      
 99 
     | 
    
         
            +
             
     | 
| 
      
 100 
     | 
    
         
            +
                  imap_from.disconnect
         
     | 
| 
      
 101 
     | 
    
         
            +
                  imap_to.disconnect
         
     | 
| 
      
 102 
     | 
    
         
            +
             
     | 
| 
      
 103 
     | 
    
         
            +
                rescue => e
         
     | 
| 
      
 104 
     | 
    
         
            +
                  @log.fatal e.message
         
     | 
| 
      
 105 
     | 
    
         
            +
             
     | 
| 
      
 106 
     | 
    
         
            +
                ensure
         
     | 
| 
      
 107 
     | 
    
         
            +
                  summary
         
     | 
| 
      
 108 
     | 
    
         
            +
                end
         
     | 
| 
      
 109 
     | 
    
         
            +
             
     | 
| 
      
 110 
     | 
    
         
            +
                # Opens a connection to the Larch message database, creating it if
         
     | 
| 
      
 111 
     | 
    
         
            +
                # necessary.
         
     | 
| 
      
 112 
     | 
    
         
            +
                def open_db(database)
         
     | 
| 
      
 113 
     | 
    
         
            +
                  filename  = File.expand_path(database)
         
     | 
| 
      
 114 
     | 
    
         
            +
                  directory = File.dirname(filename)
         
     | 
| 
      
 115 
     | 
    
         
            +
             
     | 
| 
      
 116 
     | 
    
         
            +
                  unless File.exist?(directory)
         
     | 
| 
      
 117 
     | 
    
         
            +
                    FileUtils.mkdir_p(directory)
         
     | 
| 
      
 118 
     | 
    
         
            +
                    File.chmod(0700, directory)
         
     | 
| 
      
 119 
     | 
    
         
            +
                  end
         
     | 
| 
      
 120 
     | 
    
         
            +
             
     | 
| 
      
 121 
     | 
    
         
            +
                  begin
         
     | 
| 
      
 122 
     | 
    
         
            +
                    db = Sequel.connect("sqlite://#{filename}")
         
     | 
| 
      
 123 
     | 
    
         
            +
                    db.test_connection
         
     | 
| 
      
 124 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 125 
     | 
    
         
            +
                    @log.fatal "unable to open message database: #{e}"
         
     | 
| 
      
 126 
     | 
    
         
            +
                    abort
         
     | 
| 
      
 127 
     | 
    
         
            +
                  end
         
     | 
| 
      
 128 
     | 
    
         
            +
             
     | 
| 
      
 129 
     | 
    
         
            +
                  # Ensure that the database schema is up to date.
         
     | 
| 
      
 130 
     | 
    
         
            +
                  migration_dir = File.join(LIB_DIR, 'db', 'migrate')
         
     | 
| 
      
 131 
     | 
    
         
            +
             
     | 
| 
      
 132 
     | 
    
         
            +
                  unless Sequel::Migrator.get_current_migration_version(db) ==
         
     | 
| 
      
 133 
     | 
    
         
            +
                      Sequel::Migrator.latest_migration_version(migration_dir)
         
     | 
| 
      
 134 
     | 
    
         
            +
                    begin
         
     | 
| 
      
 135 
     | 
    
         
            +
                      Sequel::Migrator.apply(db, migration_dir)
         
     | 
| 
      
 136 
     | 
    
         
            +
                    rescue => e
         
     | 
| 
      
 137 
     | 
    
         
            +
                      @log.fatal "unable to migrate message database: #{e}"
         
     | 
| 
      
 138 
     | 
    
         
            +
                      abort
         
     | 
| 
      
 139 
     | 
    
         
            +
                    end
         
     | 
| 
      
 140 
     | 
    
         
            +
                  end
         
     | 
| 
      
 141 
     | 
    
         
            +
             
     | 
| 
      
 142 
     | 
    
         
            +
                  require 'larch/db/message'
         
     | 
| 
      
 143 
     | 
    
         
            +
                  require 'larch/db/mailbox'
         
     | 
| 
      
 144 
     | 
    
         
            +
                  require 'larch/db/account'
         
     | 
| 
      
 145 
     | 
    
         
            +
             
     | 
| 
      
 146 
     | 
    
         
            +
                  db
         
     | 
| 
      
 147 
     | 
    
         
            +
                end
         
     | 
| 
      
 148 
     | 
    
         
            +
             
     | 
| 
      
 149 
     | 
    
         
            +
                def summary
         
     | 
| 
      
 150 
     | 
    
         
            +
                  @log.info "#{@copied} message(s) copied, #{@failed} failed, #{@total - @copied - @failed} untouched out of #{@total} total"
         
     | 
| 
      
 151 
     | 
    
         
            +
                end
         
     | 
| 
      
 152 
     | 
    
         
            +
             
     | 
| 
      
 153 
     | 
    
         
            +
                private
         
     | 
| 
      
 154 
     | 
    
         
            +
             
     | 
| 
      
 155 
     | 
    
         
            +
                def copy_mailbox(mailbox_from, mailbox_to)
         
     | 
| 
      
 156 
     | 
    
         
            +
                  raise ArgumentError, "mailbox_from must be a Larch::IMAP::Mailbox instance" unless mailbox_from.is_a?(Larch::IMAP::Mailbox)
         
     | 
| 
      
 157 
     | 
    
         
            +
                  raise ArgumentError, "mailbox_to must be a Larch::IMAP::Mailbox instance" unless mailbox_to.is_a?(Larch::IMAP::Mailbox)
         
     | 
| 
      
 158 
     | 
    
         
            +
             
     | 
| 
      
 159 
     | 
    
         
            +
                  return if excluded?(mailbox_from.name) || excluded?(mailbox_to.name)
         
     | 
| 
      
 160 
     | 
    
         
            +
             
     | 
| 
      
 161 
     | 
    
         
            +
                  mailbox_to.subscribe if mailbox_from.subscribed?
         
     | 
| 
      
 162 
     | 
    
         
            +
                  copy_messages(mailbox_from, mailbox_to)
         
     | 
| 
      
 163 
     | 
    
         
            +
             
     | 
| 
      
 164 
     | 
    
         
            +
                  mailbox_from.each_mailbox do |child_from|
         
     | 
| 
      
 165 
     | 
    
         
            +
                    next if excluded?(child_from.name)
         
     | 
| 
      
 166 
     | 
    
         
            +
                    child_to = mailbox_to.imap.mailbox(child_from.name, child_from.delim)
         
     | 
| 
      
 167 
     | 
    
         
            +
                    copy_mailbox(child_from, child_to)
         
     | 
| 
      
 168 
     | 
    
         
            +
                  end
         
     | 
| 
      
 169 
     | 
    
         
            +
                end
         
     | 
| 
      
 170 
     | 
    
         
            +
             
     | 
| 
      
 171 
     | 
    
         
            +
                def copy_messages(mailbox_from, mailbox_to)
         
     | 
| 
      
 172 
     | 
    
         
            +
                  raise ArgumentError, "mailbox_from must be a Larch::IMAP::Mailbox instance" unless mailbox_from.is_a?(Larch::IMAP::Mailbox)
         
     | 
| 
      
 173 
     | 
    
         
            +
                  raise ArgumentError, "mailbox_to must be a Larch::IMAP::Mailbox instance" unless mailbox_to.is_a?(Larch::IMAP::Mailbox)
         
     | 
| 
      
 174 
     | 
    
         
            +
             
     | 
| 
      
 175 
     | 
    
         
            +
                  return if excluded?(mailbox_from.name) || excluded?(mailbox_to.name)
         
     | 
| 
      
 176 
     | 
    
         
            +
             
     | 
| 
      
 177 
     | 
    
         
            +
                  imap_from = mailbox_from.imap
         
     | 
| 
      
 178 
     | 
    
         
            +
                  imap_to   = mailbox_to.imap
         
     | 
| 
      
 179 
     | 
    
         
            +
             
     | 
| 
      
 180 
     | 
    
         
            +
                  @log.info "copying messages from #{imap_from.host}/#{mailbox_from.name} to #{imap_to.host}/#{mailbox_to.name}"
         
     | 
| 
      
 181 
     | 
    
         
            +
             
     | 
| 
      
 182 
     | 
    
         
            +
                  @total += mailbox_from.length
         
     | 
| 
      
 183 
     | 
    
         
            +
             
     | 
| 
      
 184 
     | 
    
         
            +
                  mailbox_from.each_guid do |guid|
         
     | 
| 
      
 185 
     | 
    
         
            +
                    next if mailbox_to.has_guid?(guid)
         
     | 
| 
      
 186 
     | 
    
         
            +
             
     | 
| 
      
 187 
     | 
    
         
            +
                    begin
         
     | 
| 
      
 188 
     | 
    
         
            +
                      next unless msg = mailbox_from.peek(guid)
         
     | 
| 
      
 189 
     | 
    
         
            +
             
     | 
| 
      
 190 
     | 
    
         
            +
                      if msg.envelope.from
         
     | 
| 
      
 191 
     | 
    
         
            +
                        env_from = msg.envelope.from.first
         
     | 
| 
      
 192 
     | 
    
         
            +
                        from = "#{env_from.mailbox}@#{env_from.host}"
         
     | 
| 
      
 193 
     | 
    
         
            +
                      else
         
     | 
| 
      
 194 
     | 
    
         
            +
                        from = '?'
         
     | 
| 
      
 195 
     | 
    
         
            +
                      end
         
     | 
| 
      
 196 
     | 
    
         
            +
             
     | 
| 
      
 197 
     | 
    
         
            +
                      @log.info "copying message: #{from} - #{msg.envelope.subject}"
         
     | 
| 
      
 198 
     | 
    
         
            +
             
     | 
| 
      
 199 
     | 
    
         
            +
                      mailbox_to << msg
         
     | 
| 
      
 200 
     | 
    
         
            +
                      @copied += 1
         
     | 
| 
      
 201 
     | 
    
         
            +
             
     | 
| 
      
 202 
     | 
    
         
            +
                    rescue Larch::IMAP::Error => e
         
     | 
| 
      
 203 
     | 
    
         
            +
                      @failed += 1
         
     | 
| 
      
 204 
     | 
    
         
            +
                      @log.error e.message
         
     | 
| 
      
 205 
     | 
    
         
            +
                      next
         
     | 
| 
      
 206 
     | 
    
         
            +
                    end
         
     | 
| 
      
 207 
     | 
    
         
            +
                  end
         
     | 
| 
      
 208 
     | 
    
         
            +
                end
         
     | 
| 
      
 209 
     | 
    
         
            +
             
     | 
| 
      
 210 
     | 
    
         
            +
                def excluded?(name)
         
     | 
| 
      
 211 
     | 
    
         
            +
                  name = name.downcase
         
     | 
| 
      
 212 
     | 
    
         
            +
             
     | 
| 
      
 213 
     | 
    
         
            +
                  @exclude.each do |e|
         
     | 
| 
      
 214 
     | 
    
         
            +
                    return true if (e.is_a?(Regexp) ? !!(name =~ e) : File.fnmatch?(e, name))
         
     | 
| 
      
 215 
     | 
    
         
            +
                  end
         
     | 
| 
      
 216 
     | 
    
         
            +
             
     | 
| 
      
 217 
     | 
    
         
            +
                  return false
         
     | 
| 
      
 218 
     | 
    
         
            +
                end
         
     | 
| 
      
 219 
     | 
    
         
            +
             
     | 
| 
      
 220 
     | 
    
         
            +
                def glob_to_regex(str)
         
     | 
| 
      
 221 
     | 
    
         
            +
                  str.gsub!(/(.)/) {|c| GLOB_PATTERNS[$1] || Regexp.escape(c) }
         
     | 
| 
      
 222 
     | 
    
         
            +
                  Regexp.new("^#{str}$", Regexp::IGNORECASE)
         
     | 
| 
      
 223 
     | 
    
         
            +
                end
         
     | 
| 
      
 224 
     | 
    
         
            +
             
     | 
| 
      
 225 
     | 
    
         
            +
                def load_exclude_file(filename)
         
     | 
| 
      
 226 
     | 
    
         
            +
                  @exclude ||= []
         
     | 
| 
      
 227 
     | 
    
         
            +
                  lineno = 0
         
     | 
| 
      
 228 
     | 
    
         
            +
             
     | 
| 
      
 229 
     | 
    
         
            +
                  File.open(filename, 'rb') do |f|
         
     | 
| 
      
 230 
     | 
    
         
            +
                    f.each do |line|
         
     | 
| 
      
 231 
     | 
    
         
            +
                      lineno += 1
         
     | 
| 
      
 232 
     | 
    
         
            +
             
     | 
| 
      
 233 
     | 
    
         
            +
                      # Strip comments.
         
     | 
| 
      
 234 
     | 
    
         
            +
                      line.sub!(EXCLUDE_COMMENT, '')
         
     | 
| 
      
 235 
     | 
    
         
            +
                      line.strip!
         
     | 
| 
      
 236 
     | 
    
         
            +
             
     | 
| 
      
 237 
     | 
    
         
            +
                      # Skip empty lines.
         
     | 
| 
      
 238 
     | 
    
         
            +
                      next if line.empty?
         
     | 
| 
      
 239 
     | 
    
         
            +
             
     | 
| 
      
 240 
     | 
    
         
            +
                      if line =~ EXCLUDE_REGEX
         
     | 
| 
      
 241 
     | 
    
         
            +
                        @exclude << Regexp.new($1, Regexp::IGNORECASE)
         
     | 
| 
      
 242 
     | 
    
         
            +
                      else
         
     | 
| 
      
 243 
     | 
    
         
            +
                        @exclude << glob_to_regex(line)
         
     | 
| 
      
 244 
     | 
    
         
            +
                      end
         
     | 
| 
      
 245 
     | 
    
         
            +
                    end
         
     | 
| 
      
 246 
     | 
    
         
            +
                  end
         
     | 
| 
      
 247 
     | 
    
         
            +
             
     | 
| 
      
 248 
     | 
    
         
            +
                rescue => e
         
     | 
| 
      
 249 
     | 
    
         
            +
                  raise Larch::IMAP::FatalError, "error in exclude file at line #{lineno}: #{e}"
         
     | 
| 
      
 250 
     | 
    
         
            +
                end
         
     | 
| 
      
 251 
     | 
    
         
            +
             
     | 
| 
      
 252 
     | 
    
         
            +
              end
         
     | 
| 
      
 253 
     | 
    
         
            +
             
     | 
| 
      
 254 
     | 
    
         
            +
            end
         
     | 
    
        data/lib/larch/config.rb
    ADDED
    
    | 
         @@ -0,0 +1,105 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            module Larch
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            class Config
         
     | 
| 
      
 4 
     | 
    
         
            +
              attr_reader :filename, :section
         
     | 
| 
      
 5 
     | 
    
         
            +
             
     | 
| 
      
 6 
     | 
    
         
            +
              DEFAULT = {
         
     | 
| 
      
 7 
     | 
    
         
            +
                'all'              => false,
         
     | 
| 
      
 8 
     | 
    
         
            +
                'all-subscribed'   => false,
         
     | 
| 
      
 9 
     | 
    
         
            +
                'config'           => File.join('~', '.larch', 'config.yaml'),
         
     | 
| 
      
 10 
     | 
    
         
            +
                'database'         => File.join('~', '.larch', 'larch.db'),
         
     | 
| 
      
 11 
     | 
    
         
            +
                'dry-run'          => false,
         
     | 
| 
      
 12 
     | 
    
         
            +
                'exclude'          => [],
         
     | 
| 
      
 13 
     | 
    
         
            +
                'exclude-file'     => nil,
         
     | 
| 
      
 14 
     | 
    
         
            +
                'from'             => nil,
         
     | 
| 
      
 15 
     | 
    
         
            +
                'from-folder'      => 'INBOX',
         
     | 
| 
      
 16 
     | 
    
         
            +
                'from-pass'        => nil,
         
     | 
| 
      
 17 
     | 
    
         
            +
                'from-user'        => nil,
         
     | 
| 
      
 18 
     | 
    
         
            +
                'max-retries'      => 3,
         
     | 
| 
      
 19 
     | 
    
         
            +
                'no-create-folder' => false,
         
     | 
| 
      
 20 
     | 
    
         
            +
                'ssl-certs'        => nil,
         
     | 
| 
      
 21 
     | 
    
         
            +
                'ssl-verify'       => false,
         
     | 
| 
      
 22 
     | 
    
         
            +
                'to'               => nil,
         
     | 
| 
      
 23 
     | 
    
         
            +
                'to-folder'        => 'INBOX',
         
     | 
| 
      
 24 
     | 
    
         
            +
                'to-pass'          => nil,
         
     | 
| 
      
 25 
     | 
    
         
            +
                'to-user'          => nil,
         
     | 
| 
      
 26 
     | 
    
         
            +
                'verbosity'        => 'info'
         
     | 
| 
      
 27 
     | 
    
         
            +
              }.freeze
         
     | 
| 
      
 28 
     | 
    
         
            +
             
     | 
| 
      
 29 
     | 
    
         
            +
              def initialize(section = 'default', filename = DEFAULT['config'], override = {})
         
     | 
| 
      
 30 
     | 
    
         
            +
                @section  = section.to_s
         
     | 
| 
      
 31 
     | 
    
         
            +
                @override = {}
         
     | 
| 
      
 32 
     | 
    
         
            +
             
     | 
| 
      
 33 
     | 
    
         
            +
                override.each do |k, v|
         
     | 
| 
      
 34 
     | 
    
         
            +
                  k = k.to_s.gsub('_', '-')
         
     | 
| 
      
 35 
     | 
    
         
            +
                  @override[k] = v if DEFAULT.has_key?(k) && v != DEFAULT[k]
         
     | 
| 
      
 36 
     | 
    
         
            +
                end
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
                load_file(filename)
         
     | 
| 
      
 39 
     | 
    
         
            +
              end
         
     | 
| 
      
 40 
     | 
    
         
            +
             
     | 
| 
      
 41 
     | 
    
         
            +
              def fetch(name)
         
     | 
| 
      
 42 
     | 
    
         
            +
                (@cached || {})[name.to_s.gsub('_', '-')] || nil
         
     | 
| 
      
 43 
     | 
    
         
            +
              end
         
     | 
| 
      
 44 
     | 
    
         
            +
              alias [] fetch
         
     | 
| 
      
 45 
     | 
    
         
            +
             
     | 
| 
      
 46 
     | 
    
         
            +
              def load_file(filename)
         
     | 
| 
      
 47 
     | 
    
         
            +
                @filename = File.expand_path(filename)
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
      
 49 
     | 
    
         
            +
                config = {}
         
     | 
| 
      
 50 
     | 
    
         
            +
             
     | 
| 
      
 51 
     | 
    
         
            +
                if File.exist?(@filename)
         
     | 
| 
      
 52 
     | 
    
         
            +
                  begin
         
     | 
| 
      
 53 
     | 
    
         
            +
                    config = YAML.load_file(@filename)
         
     | 
| 
      
 54 
     | 
    
         
            +
                  rescue => e
         
     | 
| 
      
 55 
     | 
    
         
            +
                    raise Larch::Config::Error, "config error in #{filename}: #{e}"
         
     | 
| 
      
 56 
     | 
    
         
            +
                  end
         
     | 
| 
      
 57 
     | 
    
         
            +
                end
         
     | 
| 
      
 58 
     | 
    
         
            +
             
     | 
| 
      
 59 
     | 
    
         
            +
                @lookup = [@override, config[@section] || {}, config['default'] || {}, DEFAULT]
         
     | 
| 
      
 60 
     | 
    
         
            +
                cache_config
         
     | 
| 
      
 61 
     | 
    
         
            +
              end
         
     | 
| 
      
 62 
     | 
    
         
            +
             
     | 
| 
      
 63 
     | 
    
         
            +
              def method_missing(name)
         
     | 
| 
      
 64 
     | 
    
         
            +
                fetch(name)
         
     | 
| 
      
 65 
     | 
    
         
            +
              end
         
     | 
| 
      
 66 
     | 
    
         
            +
             
     | 
| 
      
 67 
     | 
    
         
            +
              def validate
         
     | 
| 
      
 68 
     | 
    
         
            +
                ['from', 'to'].each do |s|
         
     | 
| 
      
 69 
     | 
    
         
            +
                  raise Error, "'#{s}' must be a valid IMAP URI (e.g. imap://example.com)" unless fetch(s) =~ IMAP::REGEX_URI
         
     | 
| 
      
 70 
     | 
    
         
            +
                end
         
     | 
| 
      
 71 
     | 
    
         
            +
             
     | 
| 
      
 72 
     | 
    
         
            +
                unless Logger::LEVELS.has_key?(verbosity.to_sym)
         
     | 
| 
      
 73 
     | 
    
         
            +
                  raise Error, "'verbosity' must be one of: #{Logger::LEVELS.keys.join(', ')}"
         
     | 
| 
      
 74 
     | 
    
         
            +
                end
         
     | 
| 
      
 75 
     | 
    
         
            +
             
     | 
| 
      
 76 
     | 
    
         
            +
                if exclude_file
         
     | 
| 
      
 77 
     | 
    
         
            +
                  raise Error, "exclude file not found: #{exclude_file}" unless File.file?(exclude_file)
         
     | 
| 
      
 78 
     | 
    
         
            +
                  raise Error, "exclude file cannot be read: #{exclude_file}" unless File.readable?(exclude_file)
         
     | 
| 
      
 79 
     | 
    
         
            +
                end
         
     | 
| 
      
 80 
     | 
    
         
            +
              end
         
     | 
| 
      
 81 
     | 
    
         
            +
             
     | 
| 
      
 82 
     | 
    
         
            +
              private
         
     | 
| 
      
 83 
     | 
    
         
            +
             
     | 
| 
      
 84 
     | 
    
         
            +
              # Merges configs such that those earlier in the lookup chain override those
         
     | 
| 
      
 85 
     | 
    
         
            +
              # later in the chain.
         
     | 
| 
      
 86 
     | 
    
         
            +
              def cache_config
         
     | 
| 
      
 87 
     | 
    
         
            +
                @cached = {}
         
     | 
| 
      
 88 
     | 
    
         
            +
             
     | 
| 
      
 89 
     | 
    
         
            +
                @lookup.reverse.each do |c|
         
     | 
| 
      
 90 
     | 
    
         
            +
                  c.each {|k, v| @cached[k] = config_merge(@cached[k] || {}, v) }      
         
     | 
| 
      
 91 
     | 
    
         
            +
                end
         
     | 
| 
      
 92 
     | 
    
         
            +
              end
         
     | 
| 
      
 93 
     | 
    
         
            +
             
     | 
| 
      
 94 
     | 
    
         
            +
              def config_merge(master, value)
         
     | 
| 
      
 95 
     | 
    
         
            +
                if value.is_a?(Hash)
         
     | 
| 
      
 96 
     | 
    
         
            +
                  value.each {|k, v| master[k] = config_merge(master[k] || {}, v) }
         
     | 
| 
      
 97 
     | 
    
         
            +
                  return master
         
     | 
| 
      
 98 
     | 
    
         
            +
                end
         
     | 
| 
      
 99 
     | 
    
         
            +
             
     | 
| 
      
 100 
     | 
    
         
            +
                value
         
     | 
| 
      
 101 
     | 
    
         
            +
              end
         
     | 
| 
      
 102 
     | 
    
         
            +
             
     | 
| 
      
 103 
     | 
    
         
            +
            end
         
     | 
| 
      
 104 
     | 
    
         
            +
             
     | 
| 
      
 105 
     | 
    
         
            +
            end
         
     |