rubyでデコメのパース
前書き
受信したデコメをパースしたいと思います。
- パーサーにはTMailを使用します。
- Railsは使用しません。
- 空メの受け取り部分については書いてません。空メの内容を受け取った後の処理になります。
- 別にデコメに限らずただのテキストメールのパース、携帯でないHTMLメールのパースにも使えると思います。
取得する内容は下記のとおり
- from,toアドレス
- 件名
- テキスト本文
- HTML本文
- 添付画像(ファイル名、CID、バイナリ)
つまづいた点(解決済み)
- 添付ファイルのCID(content-id)の取得
- ドコモだけcidがとれなかった。
- subjectの文字コード問題
そのほかの問題への対策として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のもの)