「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)でエンコードしてあるような行儀の悪いメールが来るのが困りものだが、そのときはもうあきらめよう。
