【Perl】IMAP でメールを読む


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

検索条件を文字列か、ハッシュリファレンスで指定する。「題名の一部に『テストメール』を含み、『delphinus@remora.cx』から送られてきたメール」を検索するときは次のような構文になる。

文字列の場合
'SUBJECT "テストメール" FROM "delphinus@remora.cx"'
ハッシュリファレンスの場合
{
    subject => "テストメール",
    from => "delphinus@remora.cx",
}

何も条件を指定せず、すべてのメールを読むならば "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 はプラットフォームによって意味が異なるからだ。

Perlメモ
http://www.din.or.jp/~ohzaki/perl.htm#CRLF_Unify

文字コードと符号化状態を調べる(48 行目~ 60 行目)

# 文字コード
my ( $charset ) = $header =~ /Content-type:.*charset=(\S*)/;
# 符号化状態
my ( $encoding ) = $header =~ /Content-transfer-encoding:\s*(\S*)/;
# 両方とも未定義値の時はmultipartなメール
if ( !$charset and !$encoding ) {
    say "このメールは読めません";
    exit;
}

charsetContent-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::QuotedPrintMIME::Base64 という 2 つのモジュールを使っている。


後はデコードした上で、長すぎるメールを切り詰めて表示している。このスクリプトでたいていのメールは中身が確認できるはずだ。たまに、charset が空欄のくせに UTF-8(もっとひどい場合は Shift_JIS)でエンコードしてあるような行儀の悪いメールが来るのが困りものだが、そのときはもうあきらめよう。

コメントを残す