Walrus,Digit. | 一覧 | 検索 | 更新履歴(RSS) | 新規作成
はてなブックマークに追加 はてなブックマークを表示 編集 | 編集(管理者用) | 差分

Perlメモ/Perlでメールを受ける

編集

Net::POP3での受信とMIME::Parserでの解析について。 川合氏の「Perlでメールを受ける」を読み解いていった過程でまとめた両モジュールの使い方情報と、同資料で触れられているメール中の日本語の取り扱いについての補足情報です。

なお、ActivePerl 5.6系でもPPMで5.411a(2005年2月現在)のMIME-Toolsがインストールできるようになりましたので、同資料ではMIME-Tools5.410以降にあわせて書かれた「1.5 MIME-toolsの最新版への対応+HTMLメールに添付ファイル」を勉強対象にしています。

Perlでメールを受ける

はてなブックマークを表示 はてなブックマークに追加 リンク 編集

Net::POP3モジュールを利用できるなら、メールの受信は簡単です。 以下のスクリプトで、メールを受信して単純なファイル名(スクリプトのプロセスID+連番)で保存することができます。

use Net::POP3;

# メールサーバとアカウントの設定
my $server   = 'mail.example.com';
my $account  = 'myname';
my $password = 'password';
my $protocol = 'pop3';        # pop3/apop
my $output   = './mail';      # 保存先ディレクトリ

&main($server, $account, $password, $protocol, $output);

# 主処理
sub main {
  my ($server, $account, $password, $protocol, $output) = @_;
  mkdir($output, 0777) unless (-d $output);
  # Net::POP3オブジェクトを生成し、ログイン
  my $pop3  = Net::POP3->new($server) or die "Can't not open account.";
  my $login = (lc($protocol) eq 'apop') ? 'apop' : 'login';
  my $count = $pop3->$login($account, $password);
  # メールID/サイズのハッシュリファレンスを取得
  my $messages = $pop3->list();
  # メールを"<プロセスID>_<連番>_.eml"に保存
  foreach $id (sort (keys %{$messages})) {
    # $id番のメールを受信
    my $message = $pop3->get($id);
    # ファイル出力
    my $outfile = sprintf('%s/%d_%s.eml', $output, $$, $id);
    if (open(OUT, ">$outfile")) {
      print OUT @{$message};
      close OUT;
    }
    # サーバから削除(実施するなら次行をコメントアウト)
    # $pop3->delete($id);
  }
  # 接続を終了
  $pop3->quit;
}

PerlでMIMEデータ(.emlファイルの内容)を解析する

MIME::Parserを使うと、MIMEデータになっているemlデータを解析できます。

my $parser = new MIME::Parser;
$parser->output_dir('./file');
my $entity = $parser->parse_data($message);

MIME::Parserのparse_dataメソッドが呼ばれると、シンプルなテキストだけのメールであれば1つ、添付ファイルのあるメールやHTMLメール(これも実態は添付ファイルのあるメール)などmultipartのメールであれば複数のファイルを出力します。 出力先は、output_dirメソッドで指定したディレクトリ(上の例では'./file')、未指定時はカレントディレクトリです。 返り値はMIME::Entityオブジェクトです。

本文や添付ファイルは、parse_dataが出力したファイル群に格納されます。本文や添付ファイルのヘッダー情報、保存されたファイルの情報などはMIME::Entityオブジェクトである$entityから取得することができます。

ヘッダー情報の取得

MIME::Entityオブジェクトは、ヘッダ情報をMIME::Headオブジェクトとして持っています。 headメソッドでこのMIME::Headオブジェクトを取り出すことができます。

my $header = $entity->head;

MIME::Headモジュールのtagsメソッドを使ってヘッダ項目名を、getメソッドを使ってヘッダ項目の値を取得できます。ヘッダ項目名には、一般的なところで、Subject、From、To、Cc、Return-Path、Date、Message-Id、Content-Typeなどがあります。


# my @fields = qw(Subject From To Cc Return-Path Date Message-Id Content-Type);
my @fields = $header->tags;
foreach my $field (@fields) {
  print "$field : ", $header->get($field);
}

ヘッダ項目のうち、Content-TypeやContent-transfer-encoding、Content-dispositionなどは、しばしば次のように複数の情報を持っています。

Content-Type : text/plain;
        format=flowed;
        charset="iso-2022-jp";
        reply-type=original

こうした時は、mime_attrメソッドを使うと、さらにパースしたデータが得られます。 引数には、各アトリビュートを取得する時は<フィールド名.アトリビュート>、フィールドの値(上の場合Content-Typeそのもの)を取得する時は<フィールド名>を指定します。メール添付ファイルなどでは、ファイル名が"name"アトリビュートに入ってきますので、保存ファイル名を決める時の重要な情報になるでしょう。

my $field = "Content-Type";
my @attrs = qw(format charset reply-type);
print "$field : ", $header->mime_attr($field);
foreach my $attr (@attrs) {
  print "$field.$attr : ", $header->mime_attr("$field.$attr");
}

MIME::Headは、他に次のようなメソッドを持っています。

print "mime-encoding   : ", $header->mime_encoding, "\n";
print "mime-type       : ", $header->mime_type, "\n";
print "multipart_boundary : ", $header->multipart_boundary, "\n";
print "recommended_filename : ", $header->recommended_filename, "\n";

本文の取得

前述のとおり、本文などはMIME::Parserのparse_dataメソッドを実行した時点で、ファイルに保存されています。 MIME::Entityオブジェクトは、ファイル情報をMIME::Body(正確にはMIME::Body::File)オブジェクトとして持っています。 bodyhandleメソッドでこのMIME::Bodyオブジェクトを取り出すことができます。

my $body = $entity->bodyhandle;

ただし、multipartのメールでは、メール全体のMIME::Entityオブジェクトはヘッダと各パートに対応するMIME::Entityオブジェクト群を持っているだけで、自身はbodyを持っていません。 そこで、本文を取得する前に、まずmultipart形式であれば最初のパートのMIME::Entityオブジェクトを取得する必要があります。

MIME::Entityのis_multipartメソッドは、オブジェクトがmultipartか否かを返します。 partsメソッドは、引数無しで実行した場合はパート数を、引数で番号(先頭は0)を指定すればその番号のパートに対応するMIME::Entityオブジェクトを返します。 これらを使って、以下のようにします。

my $body_entity = ($entity->is_multipart) ? $entity->parts(0) : $entity;
my $body = $body_entity->bodyhandle;

$bodyに格納されているMIME::Bodyオブジェクトのas_stringメソッドを使えば全文をスカラで、as_linesメソッドを使えば配列で取得することができます。 次のようにすれば、本文を出力することができます。

print $body->as_string;

MIME::Bodyオブジェクトにはこの他に、open、close、purge(ファイルを削除する)、path(ファイルパスを返す)などのメソッドがあります。

添付ファイルの処理

multipartのメールでは、パートは複数あるのが普通です。 MIME::Parserのparse_dataメソッドを実行した時点で、この一つ一つにファイルが作成され、MIME::Entityオブジェクトが生成されます。

MIME::Entityオブジェクトのpartsメソッドは、引数無しで実行すればパート数を返します。 引数に番号を指定すれば、そのパートに対応するMIME::Entityオブジェクトを返しますので、1番から(パート数 - 1)番までのパートを処理すれば、すべての添付ファイルを処理したことになります(0番は前項に述べたように本文とみなしてよいでしょう)。 以下のようにします。

my $parts_count = $entity->parts;
for (my $i = 1; $i < $parts_count; $i++) {
  my $part_entity = $entity->parts($i);
  # パートごとの処理を行う
}

取得した$part_entityも、MIME::Entityオブジェクトですので、ここまでの内容と同様にheadやbodyhandleメソッドで内容を取得できます。

HTMLメールでの本文の区別

HTMLメールでは、ファイル本文もHTML形式の添付ファイルとして送られてきます。このため、2番目以降のパートがHTML形式の本文データなのか、それとも本当の意味で添付ファイルなのかの区別が必要です。

本文データには、ファイル名が指定されてこないようです。そこで、MIME::Headerのrecommended_headerメソッドが値を返さないものは添付ファイルではないとみなすのがよさそうです。

eml形式の添付ファイルの扱い

MIME::Parserは添付ファイルがemlファイルなどMIMEデータの場合、デフォルトではこれをさらに解析してヘッダ等を取り出そうとします。 添付ファイルはMIMEデータであってもそのままで保存したいという時には、parse_dataを行う前に、extract_nested_messagesメソッドで設定を変更しておきます。

$parser->extract_nested_messages(0);
$parser->parse_data($message);

ヘッダ情報を正しく扱うための補足情報

川合氏の「Perlでメールを受ける」ではヘッダ項目、特にメールのタイトルや保存ファイル名に含まれる日本語などを正しく扱うための手当てがかなりしっかりと行われています。 ただし、これを参考にしてもいくつか問題が発生したことがあり、それに関する補足情報を残しておきます。

ファイル名に空白が紛れ込む場合の対応

添付ファイルに長い日本語のファイル名があると、ファイル名のところどころに空白が入ることがあります。 これは、MIME::Parser::Filer::unmimeの問題のようで、Jcodeモジュールのmime_decodeを使う分にはこれが避けられます。

■ 詳細

MIME::KbParser?3の内容を利用する場合は、以下の様に書き換えます。

# my $fname = MIME::Parser::Filer::unmime $head->recommended_filename;
my $fname = jcode($head->recommended_filename)->mime_decode->sjis;

ただし、2行目ではこの時点でsjisへの変換を行っていることに注意してください。 ファイル名をeucなどこれ以外の文字コードにして保存したいのであれば、ここをさらに書き換える必要があります。

なお、Perl 5.8+Jcode 2.0の組み合わせでないと、このmime_decodeモジュールでは、"=?shift_jis?B? ... ?="のようにMIMEエンコードされている場合、デコードできません。 この場合の対応は「Jcodeモジュールでのマイムデコード」を確認してください。

■ 背景

MIME::Head::recommended_filenameメソッドは

Content-Disposition: attachment; filename="=?ISO-2022-JP?B?xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==?=
 =?ISO-2022-JP?B?xx==?="

のようにMIMEエンコードされた状態で複数行になっているファイル名を、次のように行頭の空白は残した形で連結して返します。

"=?ISO-2022-JP?B?xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==?= =?ISO-2022-JP?B?xx==?="

MIME::Parser::Filer::unmimeは、この空白をそのまま残して、=?ISO-2022-JP?B?~==?=の部分だけをデコードして返すので、おかしな空白が紛れ込むことになります。 Jcodeのmime_decodeルーチンはこの空白を削除するので、元のファイル名が得られることになります。

ただし、元のファイル名に半角空白が日本語で挟まれてたりするとどうなるかというと...未試験ですが、ちょっと心配。

ファイル名を取得できない場合の対応

添付ファイル名を取得できなくて、例えばエクセルファイルだと「msg-xxxx-x.xls」といったファイル名で保存されてしまうことがあります。 こうしたケースのうち一種類は、MIME-Toolsのバージョンを上げることで回避できます。

■ 詳細

現時点ではCPANから取得できる最新のMIME-Toolsは5.417版なのですが、CPANモジュールでのインストールではテストでNGが出てインストールできませんでした。 そこで以下のようにすることで、問題を回避できました。

  • CPANMIME::Toolsページからtar+gzファイルをダウンロード。
  • tar+gzファイルを展開。
  • 出てきたlib/MIMEを既存のMIMEモジュールのディレクトリ(C:\Perl\site\lib\MIMEとか)に上書きコピー。

もちろん、上書きコピーというのはかなり乱暴なやり方です。別のディレクトリにMIMEディレクトリをコピーし、MIME::Toolsを使用したいスクリプトで"use lib 'MIMEをコピーしたディレクトリ';"などとして当面はこちらを使い、PPMインストール可能になるのを待つ方が穏当でしょう。

■ 背景

RFC2231-encodedに沿って

Content-Disposition: attachment; 
 filename*0*=ISO-2022-JP''~ 
 filename*1*=~ 

のような形でファイル名などが送られてくるケースが該当しており、5.411a版のMIME::Field::ParamValはこれをうまく解析できません。recommended_filenameもMIME::Field::ParamValから値をもらっているだけなので、このケースでは「ファイル名なし」と言う結果を返してしまいます。

Changelogによれば、この形式への対応に当たる以下の変更が5.412版で行われたようです(yaktyさん、アドバイスThx!)。

  • lib/MIME/Field/ParamVal.pm: Deal with RFC2231-encoded parameters.

したがって、5.412以降のバージョンのMIME::Toolsを使えばこのケースではファイル名が取得できるようになります。2005年2月現在はPPMインストールでは5.412以降のバージョンをインストールできなかったので、ここではCPANの最新モジュールを何とかして利用する、という方法を採っています。

ファイル名がURLエスケープされてしまう場合の対応

MIME-Tools 5.417版と「1.5 MIME-toolsの最新版への対応+HTMLメールに添付ファイル」の内容を組み合わせると、日本語添付ファイル名が「%C6%FC%CB%DC%B8%EC%A4%CE%C4%B9%A4%A4%A5%D5.txt」などのようにURLエスケープ(?)されてしまいます。 これを回避するには、ファイル名の妥当性チェックをevil_filenameメソッドではなく、日本語文字も妥当とみなすようなメソッドを新規作成してそちらを使う必要があります。

■ 詳細

MIME::KbParser?3を使っているのであれば、まず次のメソッドを追加します。

sub evil_euc_filename {
  my ($self, $name) = @_;
  my $MPF_MaxName = $self->filer->{'MPF_MaxName'};
  
  $self->filer->debug("is this evil? '$name'");
  
  return 1 if (!defined($name) or ($name eq ''));   ### empty
  return 1 if ($name =~ m{(^\s)|(\s+\Z)});  ### leading/trailing whitespace
  return 1 if ($name =~ m{^\.+\Z});         ### dots
  return 1 if ($MPF_MaxName and (length($name) > $MPF_MaxName));
  
  my $one   = '[-a-zA-Z0-9_+=.,@\#\$\% ]';      # 1byte EUC-JP
  my $two   = '(?:[\x8E\xA1-\xFE][\xA1-\xFE])'; # 2bytes EUC-JP
  my $three = '(?:\x8F[\xA1-\xFE][\xA1-\xFE])'; # 3bytes EUC-JP
  return 1 if ($name !~ /^(?:$one|$two|$three)*$/o); # Only multibytes and allow good chars

  $self->filer->debug("it's ok");
  0;
}

次に、output_pathメソッド内でevil_filenameを呼び出している「$self->filer->evil_filename」の部分2箇所を「$self->evil_euc_filename」に置き換えます。

#   if ($self->filer->evil_filename($fname)) {
    if ($self->evil_euc_filename($fname)) {
#     if (defined($ex) and !$self->filer->evil_filename($ex)) {
      if (defined($ex) and !$self->evil_euc_filename($ex)) {

■ 背景

MIME::Tools 5.411a版のMIME::Parser::Filer::evil_filenameはファイル名中の日本語を問題視しなかったのですが、Changelogによれば5.412版で以下の修正が加えられ、日本語はチェックに引っかかるようになったようです。

  • Filer.pm: Be much more strict in evil_filename, allowing only a set of known good characters.

このため、exorcise_filenameによるエスケープ(というか浄化とかお払い?)が行われてしまいます。

そこで、evil_filenameとほぼ同等のチェックを行い、禁止文字のチェック部分だけを「マルチバイト文字とevil_filenameで許している文字だけならOK」とするevil_euc_filenameメソッドを作成し、代用するようにしました。チェックに使用するマルチバイト文字の正規表現は、大崎氏の「Perlメモ」からお借りしています。

ただし...機種依存文字なんかも通してしまうなど、マルチバイトのチェックはこれで良いのかちょっと心配。

参考

コメント

[[#rcomment]]