MH形式フォルダをMaildirに変換

必要にかられ,MH形式のフォルダをMaildirに変換するスクリプトを書きました.Rubyで.

MHからMaildirに移行する場合に便利に使えると思います.

Googleなどの検索エンジン経由で見つかるmh2maildirというツールもあるようですが,以下の点がいまいちに感じましたので,自分で作ってしまいました.

  • Maildir形式のメールを書き込むためだけにprocmailを使っている
  • 未読フラグや返信済みフラグが保存されない(Maildir形式に変更してくれない)

ファイル構成

  • mh.rb
    • mh2maildir.rbが使うクラス "Mail::Reader::MH", "Mail::Message::MH" を定義
  • maildir.rb
    • mh2maildir.rbが使うクラス "Mail::Writer::Maildir" を定義
  • mh2maildir.rb

使い方

(1) mh2maildir.rb の形で起動.
Maildirフォルダは自動的に作成されます.指定されたMaildirフォルダが既に存在する場合は動作を停止します(ぽかよけの為)

% ~/bin/mh2maildir.rb Mail Maildir-fromMH

(2) 必要に応じ,変換後のMaildirフォルダを,既存のMaildirと統合
昨日紹介したmddigest, mdmergeを使います.

% ~/bin/mddigest.rb Maildir-fromMH
% ~/bin/mddigest.rb Maildir
% ~/bin/mdmerge.rb Maildir Maildir-fromMH

実行前にMTAを止めておくこと.

注意点

  • 壊れたメールは処理しません
    • 「壊れたメール」の定義: ヘッダが一行もないMH形式のファイル
  • Courier IMAP専用です
    • 他のIMAPサーバ用のMaildirを作るためには,いくつか修正しないといけないかも
  • MH形式フォルダに,日本語フォルダ名がある場合はうまく処理できません
    • UTF-16に変換して変形Base64の処理をする必要があるようですが,面倒ですので処理してません
    • まぁ,MH形式のフォルダ名に日本語を付けている人なんていないだろうけど…….
  • MH形式フォルダ名に "." (ピリオド) を含む場合は,JIS X 0208「.」(いわゆる全角のピリオド)に変換します.
    • Courier IMAPの場合,ピリオドはIMAPフォルダの区切りに使う文字なので,変形Base64エンコーディングするのが筋ですが,そういうフォルダ名がある場合,Courier IMAP + Thunderbirdの組み合わせで正しく動かないため,やむなくこうしてあります.

ソース

mh.rb
#!/usr/local/bin/ruby
# Mail::Reader::MH class
# Mail::Message::MH class
# $Id: mh.rb 39 2008-01-08 06:37:09Z genta $

require 'time'
require 'find'

class Mail
class Reader
class MH
  attr_reader :basedir
  def initialize(basedir)
    @basedir = basedir
  end

  def traverse
    Find.find(@basedir) do |file|
      next if File.directory?(file)
      rpath = file.sub(%r|^#{Regexp.escape(@basedir)}/?|, '')
      path = rpath.split(File::SEPARATOR)

      next if path.size < 2
      folder = File.join(path[0..-2])
      filename = path[-1]

      next unless filename =~ /^\d+$/

      msg = open(folder, filename)
      next if ! msg.sanity?  # skip broken mail
      yield(msg)
    end
  end
  alias :each :traverse

  def open(folder, filename)
    file = File.join(@basedir, folder, filename)
    message = Mail::Message::MH.new(file)
    message.folder = folder
    return message
  end
end # MH
end # Reader



class Message
class MH
  attr_reader :file, :from, :time, :header, :flag
  attr_accessor :folder
  def initialize(file)
    @header = Hash.new {[]}
    @flag = {}
    @file = file
    open()
  end

  def open(file = @file)
    File.open(file) do |fd|
      key, key_h, value = '', '', ''
      fd.each do |line|
        line.strip!
        break if line =~ /^$/ # End of the header

        if /^From (\S+) *(.+)$/ === line then
          _, @from, time = $~.to_a
          @time = Time.rfc2822(time) rescue Time.parse(time)
          next
        end
        if not /^([^: \000-\037\177]+): *(.*)/ === line then
          # line is the tailer of above line
          @header[key_h][-1] += ' ' + line
          next
        end
        _, key, value = $~.to_a
        key_h = key.downcase.capitalize
        @header[key_h] = [] unless @header.key?(key_h)
        @header[key_h] <<= value
      end
    end

    %w!Seen Replied Forwarded!.
      select {|x| @header[x].size > 0 }.
      map {|x| @flag[x] = true }

    if @time.nil? then
      retryq = [
        lambda { @header['Date'][0] },
        lambda { @header['Received'].first.match(/.*;(.*)/).to_a[1] },
        lambda { File.mtime(file).to_s }
      ]
      begin
        time = retryq.shift.call
        @time = Time.rfc2822(time) rescue Time.parse(time)
      rescue
        if retryq.empty? then
          raise "Mail::Message::MH#open: Can't get the date. (file: #{file})"
        end
        retry
      end
    end
  end

  def each
    fd = File.open(@file)
    fd.each do |buf|
      _, rs = /(\r?\n)\Z/.match(buf).to_a
      next if /^(Seen|Replied|Forwarded): *$/ === buf  # cut MH flags
      if /^From / === buf then
        if not @from.nil? and not @header.key?('Return-path')
          yield("Return-Path: <#{@from}>" + rs)
        end
        next
      end
      yield(buf)
      break if /^$/ === buf
    end

    fd.each do |body|
      yield(body)
    end
    fd.close
  end

  def sanity?
    return false if @header.empty?
    return true
  end
end # MH
end # Message
end # Mail
maildir.rb
#!/usr/local/bin/ruby
# Mail::Writer::Maildir class
# $Id: maildir.rb 38 2008-01-07 18:20:21Z genta $

require 'time'

class Mail
class Writer
class Maildir
  attr_reader :maildir, :logger
  @@host = `hostname`.chomp
  @@pid = $$

  def initialize(maildir, logger = nil)
    @maildir = maildir
    if ! logger.nil? then
      @logger = lambda do
        o = logger
        dir = nil
        lambda do |tmpfile|
          if tmpfile.nil? then
            o.puts
            return
          end
          if dir.nil? or dir != tmpfile[:dir] then
            dir = tmpfile[:dir]
            o.puts
            o.print "#{dir}: "
          end
          o.print "."
        end
      end.call()
    else
      @logger = lambda {|x| x}  # do nothing
    end
    create_maildir(@maildir)
  end

  def write(reader)
    reader.traverse do |mh|
      tmpfile = tmpfile(mh)
      raise 'Internal error' if test(?e, tmpfile[:file])
      @logger.call(tmpfile)

      create_folder(tmpfile[:dir])

      File.open(tmpfile[:file], 'w') do |fd|
        mh.each {|buf| fd.print buf }
      end
      curfile = curfile(tmpfile, mh)
      File.rename(tmpfile[:file], curfile)
      File.utime(mh.time, mh.time, curfile)
    end
    @logger.call(nil)
  end


  private
  def create_maildir(maildir)
    if test(?e, maildir) then
      raise "#{maildir}: Maildir already exists"
    end
    system(*%W<maildirmake #{maildir}>) or raise "maildirmake"
  end

  def tmpfile(mh)
    t = Time.now
    time, usec = t.to_i, t.usec
    pid = @@pid
    host = @@host
    dir = mh2maildir_rpath(mh.folder)
    file = File.join(@maildir, dir, "tmp", "#{time}.M#{usec}P#{pid}_0.#{host}")
    return({:time => time, :usec => usec, :pid => pid, :host => host,
            :dir => dir, :file => file})
  end

  def curfile(t, mh)
    s = File.stat(t[:file])
    info = ''
    [['Seen', 'S'], ['Replied', 'R']].each do |(label, tag)|
      info += tag if mh.flag[label]
    end
    info = ',' + info if info.size > 0
    file = "%d.M%dP%dV%016XI%08X_0.%s,S=%d:2%s" %
      [t[:time], t[:usec], t[:pid], s.dev, s.ino, t[:host], s.size, info]

    curfile = File.join(@maildir, t[:dir], 'cur', file)
    return curfile
  end

  def mh2maildir_rpath(rpath_mh)
    rpath = rpath_mh.gsub('.', '&,w4-') # XXX: replace '.' -> JISX0208 '.'
                                        #      due to Thunderbird + Courier IMAP
                                        #      can't handle '.' in folder name.
    mpath = rpath.split(File::SEPARATOR)
    if mpath.size == 1 and mpath[0] == 'inbox' then
      return ''
    end
    # Convert MH specific folders to IMAP folders.
    [['drafts', 'Drafts'], ['trash', 'Trash']].each do |(mh, imap)|
      mpath[0] = imap if mpath[0] == mh
    end
    '.' + mpath.join('.')
  end

  def create_folder(dir)
    return nil if test(?e, File.join(@maildir, dir))

    _, folder = /^\.(.*)$/.match(dir).to_a  # strip '.' at head of dirname
    path = folder.split('.')
    path.size.times do |i|
      folder = path[0..i].join('.')
      next if test(?e, File.join(@maildir, '.' + folder))
      system(*%W<maildirmake -f #{folder} #{@maildir}>)
    end
  end
end # Maildir
end # Writer
end # Mail
mh2maildir.rb
#!/usr/local/bin/ruby
# $Id: mh2maildir.rb 37 2008-01-07 16:35:10Z genta $

$LOAD_PATH << File.dirname($0)
require 'mh'       # Mail::Reader::MH, Mail::Messgae::MH class
require 'maildir'  # Mail::Writer::Maildir
require 'pp'

def usage
  puts "usage: #{File.basename($0)} <MH directory> <Maildir>"
  puts "Warning: Maildir will be overwritten."
end

begin
  $mhdir =   ARGV[0] or raise
  $maildir = ARGV[1] or raise
rescue
  usage()
  exit(1)
end

mh = Mail::Reader::MH.new($mhdir)
maildir = Mail::Writer::Maildir.new($maildir, $stderr)

maildir.write(mh)