「MobileMe で charset 指定のないメールが文字化けする件」を書くときに Perl を使ってメールを読む方法を学んだ。そのときのまとめ。
次に示すのは、IMAP サーバにアクセスして、ある題名(の一部)に合致するメールの情報を表示するスクリプトである。
#!/usr/bin/perl use utf8; use strict; use warnings; use feature qw! say !; use Encode; use MIME::Base64; use MIME::QuotedPrint; use Net::IMAP::Client; binmode STDOUT => ":utf8"; # Windowsなら # binmode STDOUT => ":encoding(cp932)"; # サーバの設定 my $imap = Net::IMAP::Client->new( server => "mail.me.com", user => "delphinus35", pass => "パスワード", ssl => 1, port => 993, ) or die; # ログイン $imap->login or die; # 受信箱に移動 $imap->select( "INBOX" ); # メール検索開始 my $msgs = $imap->search( # 題名で抽出 { subject => "メールの題名(の一部)" }, # 日付で降順 "^DATE", ); # すべてのメールを読むなら "ALL" を指定する #my $msgs = $imap->search( "ALL", "^DATE" ); # メールが見つからなかったら終了 @$msgs or die "メールが見つかりません"; # 一番最初のメールを取り出す my $msg = ${ $imap->get_rfc822_body( $msgs->[0] ) }; # ヘッダーとメール本文を取り出す my ( $header, $body ) = $msg =~ /(.*?)(?:\x0D\x0A){2}(.*)/s; # 文字コード my ( $charset ) = $header =~ /Content-type:.*charset=(\S*)/; # 符号化状態 my ( $encoding ) = $header =~ /Content-transfer-encoding:\s*(\S*)/; # 両方とも未定義値の時はmultipartなメール if ( !$charset and !$encoding ) { say "このメールは読めません"; exit; } # 文字コードが不明なときはISO-2022-JPと推定する(ホントはダメ) $charset //= "iso_2022_jp"; $encoding //= ""; # Quoted-printable符号化の場合 if ( $encoding =~ /Quoted-printable/i ) { $body = decode_qp( $body ); # Base64符号化の場合 } elsif ( $encoding =~ /base64/i ) { $body = decode_base64( $body ); } # 指定された文字コードでデコード $body = decode( $charset, $body ); # 300文字超えてたら切り詰める 300 < length $body and substr( $body, 300 ) = ""; say "charset : $charset"; say "encoding : $encoding"; say ""; say $body;
以下、スクリプトの解説。
サーバに接続して受信トレイを開く(14 行目~ 27 行目)
# サーバの設定 my $imap = Net::IMAP::Client->new( server => "mail.me.com", user => "delphinus35", pass => "パスワード", ssl => 1, port => 993, ) or die; # ログイン $imap->login or die; # 受信箱に移動 $imap->select( "INBOX" );
ここはまあ見たまんまである。Net::IMAP::Client
モジュールさえ使えれば何の問題もなく接続できるだろう。
メールの検索(29 行目~ 40 行目)
# メール検索開始 my $msgs = $imap->search( # 題名で抽出 { subject => "メールの題名(の一部)" }, # 日付で降順 "^DATE", ); # すべてのメールを読むなら "ALL" を指定する #my $msgs = $imap->search( "ALL", "^DATE" ); # メールが見つからなかったら終了 @$msgs or die "メールが見つかりません";
search
メソッドは次のような構文になっている。
$imap->search( $criteria, $sort, $charset )
$criteria
検索条件を文字列か、ハッシュリファレンスで指定する。「題名の一部に『テストメール』を含み、『[email protected]』から送られてきたメール」を検索するときは次のような構文になる。
- 文字列の場合
-
'SUBJECT "テストメール" FROM "[email protected]"'
- ハッシュリファレンスの場合
-
{ subject => "テストメール", from => "[email protected]", }
何も条件を指定せず、すべてのメールを読むならば "ALL"
という文字列だけを指定すればよい。
$sort
並び順を文字列か、配列リファレンスで指定する。「題名で昇順、日付で降順」
を表す表現を並べると次のようになる。
"SUBJECT REVERSE DATE" "SUBJECT ^DATE" [ "SUBJECT", "REVERSE", "DATE" ] [ "SUBJECT", "reverse date" ] [ "SUBJECT", "^DATE" ]
このどれを使ってもかまわない。
$charset
検索に使う文字コードを指定する……らしいんだけど、何も指定しなくていいみたい。符号化されたヘッダーでも適切にデコードして検索してくれるようだ。
メールを取り出す(42 行目~ 43 行目)
# 一番最初のメールを取り出す my $msg = ${ $imap->get_rfc822_body( $msgs->[0] ) };
get_rfc822_body
メソッドを使うとメール全体を表す文字列へのリファレンスが取り出せる。メールが複数の部分に分かれている場合(添付ファイルがあるときとか)は困るのだが、今回はそこまで考慮していない。
メールをヘッダーと本文に分ける(45 行目~ 46 行目)
# ヘッダーとメール本文を取り出す my ( $header, $body ) = $msg =~ /(.*?)(?:\x0D\x0A){2}(.*)/s;
ここは少しやっかい。メールを最初から眺めたとき、最初の“空行”までがヘッダーで、残りが本文になる
メールの改行コードには“0x0D 0x0A
”を使うことに決まっているので、“空行”を表すなら改行コードが 2 つ連続した状態、つまり“(?:\x0D\x0A){2}
”と書くのが正しい。これを横着して“^$
”なんてやっちゃうと失敗する。“\r\n\r\n
”とやるのも NG。何となれば、\r
や \n
はプラットフォームによって意味が異なるからだ。
文字コードと符号化状態を調べる(48 行目~ 60 行目)
# 文字コード my ( $charset ) = $header =~ /Content-type:.*charset=(\S*)/; # 符号化状態 my ( $encoding ) = $header =~ /Content-transfer-encoding:\s*(\S*)/; # 両方とも未定義値の時はmultipartなメール if ( !$charset and !$encoding ) { say "このメールは読めません"; exit; }
charset
と Content-transfer-encoding
の値を調べる。両方ともが未定義値の時は、それは複数の部分に分かれた(multipart な)メールである。今回はそれを考慮しない。
# 文字コードが不明なときはISO-2022-JPと推定する(ホントはダメ) $charset //= "iso_2022_jp"; $encoding //= "";
さらに、文字コードが不明なときは ISO-2022-JP と推定している。本当はこの場合 us-ascii と推定するのが決まり事であるが、実際には ISO-2022-JP のメールである場合が多いので問題なかろう。
符号化を解除(62 行目~ 68 行目)
# Quoted-printable符号化の場合 if ( $encoding =~ /Quoted-printable/i ) { $body = decode_qp( $body ); # Base64符号化の場合 } elsif ( $encoding =~ /base64/i ) { $body = decode_base64( $body ); }
Content-transfer-encoding
の値に応じて符号化を解除する。ここではそれぞれ MIME::QuotedPrint
と MIME::Base64
という 2 つのモジュールを使っている。
後はデコードした上で、長すぎるメールを切り詰めて表示している。このスクリプトでたいていのメールは中身が確認できるはずだ。たまに、charset
が空欄のくせに UTF-8(もっとひどい場合は Shift_JIS)でエンコードしてあるような行儀の悪いメールが来るのが困りものだが、そのときはもうあきらめよう。