module Larch

Constants

APP_AUTHOR
APP_EMAIL
APP_NAME
APP_URL
APP_VERSION
EXCLUDE_COMMENT
EXCLUDE_REGEX
GLOB_PATTERNS
LIB_DIR

Attributes

config[R]
db[R]
exclude[R]
log[R]

Public Class Methods

copy_all(imap_from, imap_to, subscribed_only = false) click to toggle source

Recursively copies all messages in all folders from the source to the destination.

# File lib/larch.rb, line 51
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
copy_folder(imap_from, imap_to) click to toggle source

Copies the messages in a single IMAP folder and all its subfolders (recursively) from the source to the destination.

# File lib/larch.rb, line 85
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
init(config) click to toggle source
# File lib/larch.rb, line 31
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
open_db(database) click to toggle source

Opens a connection to the Larch message database, creating it if necessary.

# File lib/larch.rb, line 112
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
summary() click to toggle source
# File lib/larch.rb, line 148
def summary
  @log.info "#{@copied} message(s) copied, #{@failed} failed, #{@deleted} deleted out of #{@total} total"
end

Private Class Methods

copy_mailbox(mailbox_from, mailbox_to) click to toggle source
# File lib/larch.rb, line 156
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
copy_messages(mailbox_from, mailbox_to) click to toggle source
# File lib/larch.rb, line 174
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
db_maintenance() click to toggle source
# File lib/larch.rb, line 261
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
excluded?(name) click to toggle source
# File lib/larch.rb, line 272
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
glob_to_regex(str) click to toggle source
# File lib/larch.rb, line 282
def glob_to_regex(str)
  str.gsub!(/(.)/) {|c| GLOB_PATTERNS[$1] || Regexp.escape(c) }
  Regexp.new("^#{str}$", Regexp::IGNORECASE)
end
load_exclude_file(filename) click to toggle source
# File lib/larch.rb, line 287
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
parse_exclusions() click to toggle source
# File lib/larch.rb, line 314
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