複数のMaildirをひとつにマージするスクリプト

複数のMaildirに格納されたメールを,ひとつのMaildirに統合するスクリプトを書きました.Rubyで.

バックアップからMaildirをリストアする時などに使えるかもしれないので,公開しておきます.

概要

  • 指定された2つのMaildirに格納されたメールを,ひとつのMaildirに全てコピーする
  • 重複したメールはコピーしない
  • IMAPフォルダが存在しない場合には,maildirmake(1)コマンドにより作成
  • Courier IMAP

ファイル構成

  • mdd.rb
    • MDDigestクラスを定義.mddigest.rbとmdmerge.rbが使用する.
    • GDBMライブラリを使用.
  • mddigest.rb
  • mdmerge.rb
    • 2つのMaildirフォルダをひとつに統合する.

使い方

マージ元とマージ先のMaildirがそれぞれ以下のようになっているものとする.

  • マージ先
    • /home/user/Maildir
  • マージ元
    • /home/user/Maildir-backup

(1) MTAとIMAPサーバを停止する
(2) 2つのMaildirに対し,mddigest.rbを実行
この操作により,それぞれのMaildirの直下に "mddigest" ファイルが作成される.

% ~/bin/mddigest.rb /home/user/Maildir
% ~/bin/mddigest.rb /home/user/Maildir-backup

(3) マージ先のMaildirをバックアップ
マージ先のMaildirは上書きされるため,念のためにバックアップする.
Maildirでは,ファイルのmtimeをメール到着時間として利用するため,cp -pオプションを付けておく.

% cp -Rp /home/user/Maildir /home/user/Maildir.bak.mdmerge

(4) mdmerge.rb実行

% ~/bin/mdmerge.rb /home/user/Maildir /home/user/Maildir-backup

これにより,Maildir-backupに存在するメール(およびIMAPフォルダ)はすべてMaildirにコピーされる.

(5) IMAPサーバを起動し,メーラから確認
(6) MTAを起動

ソース

mdd.rb
#!/usr/local/bin/ruby
# $Id: mdd.rb 29 2008-01-05 08:25:26Z genta $
require 'gdbm'
require 'digest/sha1'
require 'pp'


# Maildir digest file class
class MDDigest
  attr_reader :path, :basedir, :db
  def initialize(maildir)
    @basedir = maildir.dup
    @path = File.join(@basedir, 'mddigest')
    open()
  end

  def open;  @db = GDBM.open(@path); end
  def close; @db.reorganize.close;   end
  def clear; @db.clear;              end

  def add(path)
    apath = abspath(path)

    md = hash(apath).hexdigest
    size = File.size(apath)
    key = md + '|' + size.to_s

    if @db.key?(key) then
      merge(key, path)
      return
    end
    @db[key] = path
  end

  def has_file?(key, file)
    return false if @db[key].nil?
    @db[key].split('\0').each do |src|
      return true if src == file
    end
    return false
  end

  def each(&block)
    @db.each(&block)
  end


  def hash(file)
    md = Digest::SHA1.new
    File.open(file, 'r') do |fd|
      buf = ''
      while fd.read(256, buf)
        md << buf
      end
    end
    return md
  end

  def abspath(file)
    File.expand_path(file, @basedir)
  end

  def merge(key, dest)
    dfolder = folder(abspath(dest))
    list = @db[key].split('\0')
    list.map! do |src|
      return if src == dest
      if folder(src) == dfolder then
        return if File.mtime(abspath(dest)) < File.mtime(abspath(src))
        next
      end
      src
    end.compact!
    list += [dest]
    raise "Idential" if list.join('\0') == @db[key]
    @db[key] = list.join('\0')
  end

  def folder(path)
    f, = path.split('/')
    return '' unless f =~ /^\./
    return f
  end
end
mddigest.rb
#!/usr/local/bin/ruby
# mddigest.rb -- Make message digest file of specified Maildir.
# $Id: mddigest.rb 35 2008-01-06 13:57:06Z genta $

$LOAD_PATH << File.dirname($0)
require 'mdd'  # MDDigest class
require 'find'
require 'pp'



def usage
  puts <<-_EOD_
#{File.basename($0)} [Maildir] [digest_filename]
  _EOD_
end

def getargs
  $home = ENV['HOME'] or raise "HOME environment undefined"
  $maildir = ARGV[0] || $home + '/Maildir'
end



getargs()
md = MDDigest.new($maildir)
md.clear

Find.find($maildir) do |fpath|
  rpath = fpath.sub(%r|^#{Regexp.escape($maildir)}/?|, '')
  folder, = path = rpath.split(%r|/|)
  if test(?d, fpath) then
    case path.size
    when 0; next
    when 1, 2
      next if path[-1] =~ /^(cur|new|tmp)$/
      Find.prune if path[-1] =~ /^courierimap(keywords|hieracl)$/
      next if folder =~ /^\.[^\/]+$/
    else; raise "#{rpath}: Not impremented this case"
    end
    raise "path: #{path.inspect}: Internal error"
  end

  file = path[-1]
  next if file =~ /^courierimap(subscribed|uiddb)$/
  next if file =~ /^procmail\.log$|^mddigest(\.\w+)?$/
  raise "#{file}: Internal error (path.size = #{path.size})" if path.size < 2

  # Now, folder: folder's name,  file: filename of entire mail.
  # and, (2 <= path.size and path.size <=3) == true.
  raise "#{path.inspect}: Internal error" if (path.size < 2 or 3 < path.size)
  next if file =~ /^(courierimapacl|maildirfolder)$/

  md.add(rpath)
end
md.close
__END__
mdmerge.rb
#!/usr/local/bin/ruby
# mdmerge.rb -- Merge two maildir into one.
# $Id: mdmerge.rb 36 2008-01-06 13:58:51Z genta $

$LOAD_PATH << File.dirname($0)
require 'mdd'  # MDDigest class
require 'pp'



class CMD
  attr_reader :fileq, :dirq, :skipdir, :basedir, :fromdir
  def initialize(basedir, fromdir)
    @fileq, @dirq = [], []
    @skipdir = []
    @basedir = basedir.dup
    @fromdir = fromdir.dup
  end

  def add(file)
    mkdir(file)
    cpfile(file)
  end

  def cpfile(rpath)
    return if @fileq.include?(rpath)
    @fileq << rpath
  end

  def mkdir(rpath)
    rdir = File.dirname(rpath)
    path = rdir.split(File::SEPARATOR)
    path.pop if path[-1] =~ /^(cur|new|tmp)$/
    rdir = File.join(path)
    adir = abspath(rdir)

    return if @dirq.include?(rdir) or @skipdir.include?(rdir)
    if test(?d, adir) then
      puts "mkdir: already exists: #{adir}"
      @skipdir << rdir
      return
    end
    @dirq << rdir
  end

  def commit_check
    @dirq.each do |dir|
      puts "mkdir: #{dir}"
    end

    @fileq.each do |file|
      puts "cp: #{file}"
    end
  end

  def commit
    do_mkdir
    do_cpfile
  end


  def do_mkdir
    @dirq.each do |rpath|
      folder = rpath.split(File::SEPARATOR)[0].sub(/^\./, '')
      #puts "maildirmake: #{folder} (from: #{rpath}, basedir: #{@basedir})"
      system('maildirmake', '-f',  folder, @basedir) or
        raise "maildirmake: #{$?}"
    end
  end

  def do_cpfile
    @fileq.each do |rpath|
      tofile = File.expand_path(rpath, @basedir)
      frfile = File.expand_path(rpath, @fromdir)

      system('cp', '-p', frfile, tofile) or raise "cp: #{$?}"
    end
  end

  def abspath(file)
    File.expand_path(file, @basedir)
  end
end

def usage
  puts <<-_EOD_
#{File.basename($0)} [base-maildir] [merge-from-maildir]
Note: base-maildir will be overwritten.  Please backup first.
  _EOD_
end

def getargs
  $home = ENV['HOME'] or raise "HOME environment undefined"
  if ARGV.size != 2 then
    usage()
    exit
  end
  $src, $dest = ARGV[0..1]
end



getargs()
smd = MDDigest.new($src)
dmd = MDDigest.new($dest)
cmd = CMD.new(smd.basedir, dmd.basedir)

dmd.each do |key, file|
  files = file.split('\0')
  files.each do |file|
    cmd.add(file) unless smd.has_file?(key, file)
  end
end
cmd.commit

smd.close
dmd.close
__END__
mdview.rb

おまけ.mddigest.rbで作成したハッシュ値格納ファイルの中身を閲覧する.

#!/usr/local/bin/ruby
# mdview.rb -- view gdbm file
# $Id: mdview.rb 23 2008-01-04 19:40:43Z genta $

require 'gdbm'
require 'pp'

def view(file)
  unless $flag_multiply then
    pp GDBM.open(file).map
    return
  end

  GDBM.open(file).each do |key, value|
    values = value.split('\0')
    next if values.size <= 1
    pp [key, *values]
  end
end

if ARGV[0] =~ /^-m/ then
  $flag_multiply = true; ARGV.shift
end
file = ARGV[0] or raise "need arg.\nUsage: #{File.basename($0)} gdbm-file\n"
raise "#{file}: Not exists or not a file" unless test(?f, file)

view(file)