odeの開発メモ日記

プログラマーやってます。今までの道のり ポケコンbasic→C(DirectX5 ネットやろうぜ)→Perl(ちょろっと)→Java→C#→Ruby→Android(Java)

rubyでデコメのパース

前書き

受信したデコメをパースしたいと思います。

  • パーサーにはTMailを使用します。
  • Railsは使用しません。
  • 空メの受け取り部分については書いてません。空メの内容を受け取った後の処理になります。
  • 別にデコメに限らずただのテキストメールのパース、携帯でないHTMLメールのパースにも使えると思います。


取得する内容は下記のとおり

  • from,toアドレス
  • 件名
  • テキスト本文
  • HTML本文
  • 添付画像(ファイル名、CID、バイナリ)

つまづいた点(解決済み)

  • 添付ファイルのCID(content-id)の取得
    • ドコモだけcidがとれなかった。
  • subjectの文字コード問題
    • デコードされた結果がエンコード前の文字コードになる。(プログラムではUTF8で取得したいのにJISでとれてしまう。。UTF8の件名はUTF8でとれる。)

そのほかの問題への対策としてMbMailを使わせて頂きました。
下記の対策がなされています。

  • 携帯等のRFC違反のドットが連続するアドレスが扱えるよう、TMail::Parser を置き換え。
  • 機種依存文字の対応(絵文字等)、文字コード変換器に NKF を使用するように変更。デフォルトで使われるiconvだと対応していない。

multipartのパースのためのutilとしても使いました。(privateメソッドだったので若干ソース修正してます)
http://tmty.jp/tag/mbmail/


これらの問題を解決するためにTMailにパッチをあてます。
私の場合はMbMailに含まれるTMailのソースに上書きしました。
また、MbMailがRailsに依存しないで動くように修正してあります。

ソース

メイン文

$KCODE = 'u'
require 'rubygems'
require 'tmail'
require 'tmtysk-mbmail/lib/mb_mail.rb'

def parse_mail(file_data)
  puts "---------------------"
  mail = MbMail::DMail.parse(file_data)
  mail.organize_mail_parts

  p "to: "+mail.to.to_s # toは配列
  p "from: "+mail.from.to_s # fromは配列
  p "subject: #{mail.subject}"
  p "text: "+mail.text_part.body

  html = ""
  unless mail.html_part.nil?
    html = mail.html_part.body
  end
  p "html: "+html

  mail.in_lined_image_parts.each_with_index do |ip, index|
    # なぜかドコモだけcidがとれなかった。内部の@bodyには入ってたので無理やり取得する。
    #p "cid: "+ip["content-id"].id.to_s
    content_id_ip = ip["content-id"]
    def content_id_ip.inner_body
      @body
    end
    content_id = ""
    unless content_id_ip.inner_body.nil?
      content_id = content_id_ip.inner_body.strip
      # cidは<>で囲まれてるので除去する。
      if content_id[0, 1] == "<"
        content_id = content_id[1, content_id.length-1]
      end
      if content_id[content_id.length-1, 1] == ">"
        content_id = content_id[0, content_id.length-1]
      end
    end

    file_name = (ip['content-location'] && ip['content-location'].body) ||
      ip.sub_header("content-type", "name") ||
      ip.sub_header("content-disposition", "filename")
    file_name = TMail::Unquoter.unquote_and_convert_to(file_name, 'utf-8')

    p "file #{index+1}"
    p "file_name: #{file_name}"
    p "cid: #{content_id}"
    #p ip.body #ファイルのバイナリ
  end

  puts ""
  puts ""
end


file_names = ["/tmp/test1.eml", "/tmp/test2.eml"]
file_names.each do |file_name|
  file = File.open(file_name) do |f|
    parse_mail f.read
  end
end

tmtysk-mbmail/lib/mb_mail/dmail.rb

# ode改良 start railsでなくなった対応
require 'base64'
# ode改良 end

# 携帯メール対応モジュール
module MbMail
  # HeaderField 操作用に TMail から移管
  class HeaderField < TMail::HeaderField; end

  # デコメールクラス
  class DMail < TMail::Mail

    def []=( key, val )
      dkey = key.downcase

      if val.nil?
        @header.delete dkey
        return nil
      end

      case val
        when String
        header = new_hf(key, val)
        when HeaderField
        # HeaderField が与えられた場合、そのままヘッダに代入する
        header = val
        when Array
        ALLOW_MULTIPLE.include? dkey or
        raise ArgumentError, "#{key}: Header must not be multiple"
        @header[dkey] = val
        return val
      else
        header = new_hf(key, val.to_s)
      end
      if ALLOW_MULTIPLE.include? dkey
       (@header[dkey] ||= []).push header
      else
        @header[dkey] = header
      end

      val
    end

    # docomo のデコメールフォーマットに変換する
    def to_docomo_format
      converted_for_carrier(:docomo)
    end

    # au のデコレーションメールフォーマットに変換する
    def to_au_format
      converted_for_carrier(:au)
    end

    # softbank のデコレメールフォーマットに変換する
    def to_softbank_format
      converted_for_carrier(:softbank)
    end

    protected

    # 指定された content-type のパーツを取得する
    def get_specified_type_parts(mimetype)
      specified_type_parts = []
      specified_type_parts << self if Regexp.new("^#{mimetype}$", Regexp::IGNORECASE) =~ self.content_type
      if /^multipart\/(.+)$/ =~ self.content_type then
        self.parts.each do |p|
          specified_type_parts += p.get_specified_type_parts(mimetype)
        end
      end
      specified_type_parts
    end

    private

    # 指定のキャリアのデコメールフォーマットに変換する
    # 現時点では、:docomo, :au, :softbank のみ対応
    def converted_for_carrier(carrier = :docomo)
      organize_mail_parts

      dm = MbMail::DMail.new
      self.header.each do |key,value|
        next if key == 'content-type' # content-type は引き継いだらダメ
        dm[key] = value.to_s
      end
      dm.body = ""
      dm.content_type = 'multipart/mixed'

      # text/plain パートの作成
      tp = MbMail::DMail.new
      case carrier
        when :docomo
        tp.content_type = 'text/plain; charset="Shift_JIS"'
        tp.transfer_encoding = 'Base64'
        tp.body = Base64.encode64(Jpmobile::Emoticon::unicodecr_to_external(NKF.nkf('-m0 -x -Ws', @text_part.body), Jpmobile::Emoticon::CONVERSION_TABLE_TO_DOCOMO))
        when :au
        tp.content_type = 'text/plain; charset="Shift_JIS"'
        tp.transfer_encoding = 'Base64'
        tp.body = Base64.encode64(Jpmobile::Emoticon::unicodecr_to_external(NKF.nkf('-m0 -x -Ws', @text_part.body), Jpmobile::Emoticon::CONVERSION_TABLE_TO_AU))
        when :softbank
        tp.content_type = 'text/plain; charset="UTF-8"'
        tp.transfer_encoding = 'Base64'
        table = Jpmobile::Emoticon::CONVERSION_TABLE_TO_SOFTBANK
        emoticon_converted = @text_part.body.gsub(/&#x([0-9a-f]{4});/i) do |match|
          unicode = $1.scanf("%x").first
          case table[unicode]
            when Integer
            [(table[unicode].to_i-0x1000)].pack('U')
            when String
            table[unicode]
          else
            match
          end
        end
        tp.body = Base64.encode64(emoticon_converted)
      else
        tp.content_type = 'text/plain; charset="UTF-8"'
        tp.transfer_encoding = 'Base64'
        tp.body = Base64.encode64(@text_part.body)
      end

      # text/html パートの作成
      # ode改良 start テキストメールのみの場合対応
      if @html_part.nil?
        hp = nil
      else
        hp = MbMail::DMail.new
        case carrier
          when :docomo
          hp.content_type = 'text/html; charset="Shift_JIS"'
          hp.transfer_encoding = 'Base64'
          hp.body = Base64.encode64(Jpmobile::Emoticon::unicodecr_to_external(NKF.nkf('-m0 -x -Ws', @html_part.body), Jpmobile::Emoticon::CONVERSION_TABLE_TO_DOCOMO))
          when :au
          hp.content_type = 'text/html; charset="Shift_JIS"'
          hp.transfer_encoding = 'Base64'
          hp.body = Base64.encode64(Jpmobile::Emoticon::unicodecr_to_external(NKF.nkf('-m0 -x -Ws', @html_part.body), Jpmobile::Emoticon::CONVERSION_TABLE_TO_AU))
          when :softbank
          hp.content_type = 'text/html; charset="UTF-8"'
          hp.transfer_encoding = 'Base64'
          table = Jpmobile::Emoticon::CONVERSION_TABLE_TO_SOFTBANK

          emoticon_converted = @html_part.body.gsub(/&#x([0-9a-f]{4});/i) do |match|
            unicode = $1.scanf("%x").first
            case table[unicode]
              when Integer
              [(table[unicode].to_i-0x1000)].pack('U')
              when String
              table[unicode]
            else
              match
            end
          end
          hp.body = Base64.encode64(emoticon_converted)
        else
          hp.content_type = 'text/plain; charset="UTF-8"'
          hp.transfer_encoding = 'Base64'
          hp.body = Base64.encode64(@html_part.body)
        end
      end
      # ode改良 end

      # キャリアによって multipart 構成を分岐
      alt_p = MbMail::DMail.new
      alt_p.body = ""
      alt_p.content_type = 'multipart/alternative'
      alt_p.parts << tp
      # ode改良 start テキストメールのみの場合対応
      if hp
        alt_p.parts << hp
      end
      # ode改良 end
      case carrier
        when :au
        dm.parts << alt_p
        @in_lined_image_parts.each do |ip| dm.parts << ip end
        @attached_image_parts.each do |ap| dm.parts << ap end
      else
        rel_p = MbMail::DMail.new
        rel_p.body = ""
        rel_p.content_type = 'multipart/related'
        rel_p.parts << alt_p
        @in_lined_image_parts.each do |ip| rel_p.parts << ip end
        dm.parts << rel_p
        @attached_image_parts.each do |ap| dm.parts << ap end
      end

      dm
    end

    # オリジナルのメール構成を解析し、各パーツに分離する
    def organize_mail_parts
      @text_part = get_specified_type_parts('text/plain').first
      @html_part = get_specified_type_parts('text/html').first
      # いったん全ての画像をインライン扱いとし、
      # その後本文から参照されていない画像を添付扱いとする
      # au でいずれの添付タイプについても同等に扱われているため
      @in_lined_image_parts = get_specified_type_parts('image/(gif|jpeg|jpg)')
      @in_lined_image_parts.map do |ip| ip.content_disposition = 'inline' end
      @attached_image_parts = []
      @in_lined_image_parts.delete_if do |ip|
        if /TMail\:\:MessageIdHeader\s\"<([\.@_0-9a-zA-Z]+)>/ =~ ip["content-id"].inspect then
          unless Regexp.new("src=['\"]cid:#{$1}", Regexp::IGNORECASE) =~ @html_part.body then
            ip.content_disposition = 'attachment'
            @attached_image_parts << ip
            true
          end
        else
          false
        end
      end
    end

    # ode改良 start 外部から使えるようにする。
    public :organize_mail_parts
    public
    attr_accessor :text_part, :html_part, :in_lined_image_parts
    # ode改良 end

  end
end

tmtysk-mbmail/lib/mb_mail/mb_mailer.rb

# ode改良 start rails依存のを除去
module MbMail
  # http://www.kbmj.com/~shinya/rails_seminar/slides/#(33)
  # JapaneseMailer 実装より
  class MbMailer
    Dir[File.join(File.dirname(__FILE__), '../tmail/**/*.rb')].sort.each { |f| require f }

    public
    # ヘッダに日本語を含める場合に用いる base64 カプセリング
    # http://wiki.fdiary.net/rails/?ActionMailer
    def base64(text, charset="iso-2022-jp", convert=true)
      if convert
        if charset == "iso-2022-jp"
          text = NKF.nkf('-j -m0', text)
        end
      end
      text = [text].pack('m').delete("\r\n")
      "=?#{charset}?B?#{text}?="
    end
  end
end
# ode改良 end

tmtysk-mbmail/lib/tmail/unquoter.rb

# ode改良 start alias_method_chainを使えるようにした。
module ActiveSupport
  module CoreExtensions
    module Module
      # Encapsulates the common pattern of:
      #
      #   alias_method :foo_without_feature, :foo
      #   alias_method :foo, :foo_with_feature
      #
      # With this, you simply do:
      #
      #   alias_method_chain :foo, :feature
      #
      # And both aliases are set up for you.
      #
      # Query and bang methods (foo?, foo!) keep the same punctuation:
      #
      #   alias_method_chain :foo?, :feature
      #
      # is equivalent to
      #
      #   alias_method :foo_without_feature?, :foo?
      #   alias_method :foo?, :foo_with_feature?
      #
      # so you can safely chain foo, foo?, and foo! with the same feature.
      def alias_method_chain(target, feature)
        # Strip out punctuation on predicates or bang methods since
        # e.g. target?_without_feature is not a valid method name.
        aliased_target, punctuation = target.to_s.sub(/([?!=])$/, ''), $1
        yield(aliased_target, punctuation) if block_given?

        with_method, without_method = "#{aliased_target}_with_#{feature}#{punctuation}", "#{aliased_target}_without_#{feature}#{punctuation}"

        alias_method without_method, target
        alias_method target, with_method

        case
          when public_method_defined?(without_method)
          public target
          when protected_method_defined?(without_method)
          protected target
          when private_method_defined?(without_method)
          private target
        end
      end

      # Allows you to make aliases for attributes, which includes
      # getter, setter, and query methods.
      #
      # Example:
      #
      #   class Content < ActiveRecord::Base
      #     # has a title attribute
      #   end
      #
      #   class Email < Content
      #     alias_attribute :subject, :title
      #   end
      #
      #   e = Email.find(1)
      #   e.title    # => "Superstars"
      #   e.subject  # => "Superstars"
      #   e.subject? # => true
      #   e.subject = "Megastars"
      #   e.title    # => "Megastars"
      def alias_attribute(new_name, old_name)
        module_eval <<-STR, __FILE__, __LINE__+1
          def #{new_name}; self.#{old_name}; end          # def subject; self.title; end
          def #{new_name}?; self.#{old_name}?; end        # def subject?; self.title?; end
          def #{new_name}=(v); self.#{old_name} = v; end  # def subject=(v); self.title = v; end
        STR
      end
    end
  end
end

class Module
  include ActiveSupport::CoreExtensions::Module
end
# ode改良 end


require 'nkf'
# convert_to で機種依存文字や絵文字に対応するために
# Unquoter 内で NKF を使用するようにしたもの
module TMail
  class Unquoter
    class << self

      def unquote_and_convert_to(text, to_charset, from_charset = "iso-8859-1", preserve_underscores=false)
        return "" if text.nil?
        text.gsub(/(.*?)(?:(?:=\?(.*?)\?(.)\?(.*?)\?=)|$)/) do
          before = $1
          from_charset = $2
          quoting_method = $3
          text = $4

#          p "#{before} #{from_charset} #{quoting_method} #{text}"

          before = convert_to(before, to_charset, from_charset) if before.length > 0
          before + case quoting_method
            when "q", "Q" then
            unquote_quoted_printable_and_convert_to(text, to_charset, from_charset, preserve_underscores)
            when "b", "B" then
            unquote_base64_and_convert_to(text, to_charset, from_charset)
            when nil then
            # will be nil at the end of the string, due to the nature of
            # the regex used.
                ""
          else
            raise "unknown quoting method #{quoting_method.inspect}"
          end
        end
      end

      # http://www.kbmj.com/~shinya/rails_seminar/slides/#(30)
      def convert_to_with_nkf(text, to, from)
#        p text, to, from
        if text && to =~ /^utf-8$/i && from =~ /^iso-2022-jp$/i
          NKF.nkf("-Jw", text)
        elsif text && from =~ /^utf-8$/i && to =~ /^iso-2022-jp$/i
          NKF.nkf("-Wj", text)
        else
          convert_to_without_nkf(text, to, from)
        end
      end

      alias_method_chain :convert_to, :nkf
    end
  end
end


# ode改良 start subjectがJISでデコードされる不具合のため対応。
module TMail
  class Decoder
    def self.decode( str, encoding = nil )
      #何もしない。実際のデコードはUnquoter#convert_toで行われるため。
      return str
    end
  end
end
#ode改良 end


# 下記のバージョンでutf8としてデコードするでもOK。(TMail.KCODEをUTF8にしておく必要あり)
# だけど後で実際のデコードをUnquoter#convert_toでもっと汎用的に行うのでここでデコードに必要な情報をかけさせるのも微妙かと思う。
# あとここのデコードはJIS、EUC、SJISだけという日本専用になっている。多分ここはTMailの管理が日本のときのなごりなのかも!?
#module TMail
#  class Decoder
#    OUTPUT_ENCODING = {
#      'EUC'  => 'e',
#      'SJIS' => 's',
#      'UTF8' => 'w', #utf8を追加
#    }
#    def self.decode( str, encoding = nil )
#      encoding ||= (OUTPUT_ENCODING[TMail.KCODE] || 'j')
#      opt = '-mS' + encoding
#      str.gsub(ENCODED_WORDS) {|s| NKF.nkf(opt, s) }
#      str
#    end
#
#  end
#end

実行環境

ruby (1.8.6)
tmail (1.2.3.1)
MbMail (verはないっぽい。2008-12-24のもの)