multipart/related HTML mail decoding - MIME::Parser

あるメールマガジンが Content-Type: multipart/related の HTML メール形式で来る。この場合,メールのアーカイブで使用している MHonArc では,残念ながら,画像等がドロップしてしまう。別途 Web アーカイブとしてこのメールマガジンを閲覧できるようにするために,HTML メールを解析して通常の HTML + 画像ファイルにデコードする Perl プログラムを書いた。

multipart/releted メール構造

multipart/related のメール構造の例は以下のようなものである。HTML メールを表示出来ないソフトのための代替テキスト,quoted-printable でエンコードされた HTML,そして Base64 でエンコードされた画像ファイル(HTML から参照されるもので,複数ありうる)のパートからなる,マルチパートのメールメッセージである。例では画像が二つ格納されている。... で省略を,[ ] 内に私の注釈を示してある。

...
Mime-Version: 1.0
Content-Type: multipart/related;
 boundary="L8jCUsh5"
Subject: =?ISO-2022-JP?B?Gy...
Message-ID: <833032597.31891656.1443556497891.Mail.root@xxx.yyy.jp>
User-Agent: Mail/5.1.1
From: ZZZZ <mailmagazine@yyy.jp>
To: isao@yasuda.homeip.net
Date: Fri, 2 Oct 2015 18:01:37 +0900 (JST)
 
--L8jCUsh5
Content-Type: multipart/alternative;
 boundary="----=_NextPart_000_000D_01C575C8.BEFD7DC0"
 
------=_NextPart_000_000D_01C575C8.BEFD7DC0
Content-Type: text/plain; charset=ISO-2022-JP
Content-Transfer-Encoding: 7bit
 
いつもお世話に...
[ HTMLメールが表示できないメールソフトのための代替テキスト ]
 
------=_NextPart_000_000D_01C575C8.BEFD7DC0
Content-Type: text/html; charset=ISO-2022-JP
Content-Transfer-Encoding: quoted-printable
 
<HTML><HEAD><META http-equiv=3D'Content-Type' content=3D'text/html; charset=
... [ quoted-printable encode の HTML 本体 ] 
 
------=_NextPart_000_000D_01C575C8.BEFD7DC0--
 
--L8jCUsh5
Content-Type: image/jpeg;
 name="14437727600.jpeg"
Content-Transfer-Encoding: base64
Content-ID: <1f1443575835@DECOIMG>
 
/9j/4AAQSkZJRgABAQAAAQABAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcg
... [ jpeg base64 encode の画像データ ]
 
--L8jCUsh5
Content-Type: image/jpeg;
 name="14437727601.jpeg"
Content-Transfer-Encoding: base64
Content-ID: <1f1443772082@DECOIMG>
 
/9j/4AAQSkZJRgABAQAAAQABAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcg
... [ jpeg base64 encode の画像データ ]
 
--L8jCUsh5--

プログラム設計

MIME::Parser Perl モジュールを利用すると簡単にデコードが出来る。Content-Type,Content-Transfer-Encoding を意識しなくても勝手にデコードしてくれる。

ただし,このモジュールはパースしたマルチパートすべてをファイル出力するデフォルト仕様になっており,必要なものを意図をもって書き出す以外は自動出力を抑止したい,というときは,$parser->output_to_core(1); を書いておく。

また,MIME::Parser モジュールは,ヘッダの Subject が ISO-2022-JP MIME エンコードの場合,デコードできないようである。本プログラムでは Encode モジュールの機能によりデコードすることとした。

プログラムの処理仕様は以下のとおり。

  1. 生成する HTML ファイル名は現在日付+メールメッセージファイル名(数字)4桁で整形したものとする。
  2. HTML エンコードを ISO-2022-JP から UTF-8 に変換する。
  3. <title> を設定する。この HTML パートのテキストには <title> タグがない。メールヘッダの Subject: のデコード文字列を <title> に設定することとした。
  4. 画像 src 属性の Content-ID 指定を画像ファイル名で置き換える。このメールマガジンでは,image/jpeg パートのヘッダで画像に Content-ID を振っており,HTML の <img> タグ src 属性がこの Content-ID で指定されている。たとえば,上記メール例では,一つ目の画像ファイル 14437727600.jpeg を用いる <img> タグの記述は,<img src='cid:1f1443575835@DECOIMG'> とマークアップされている。よって,実際に閲覧可能な HTML を生成するには,この cid:1f1443575835@DECOIMG の Content-ID 指定をそれに対応する画像ファイル名で置き換えなくてはならない。画像は複数挿入されている場合があり,Content-ID と画像ファイル名の対を配列に格納して,すべての画像を取り出したあとで,HTML の <img src='cid:xxx'../>cid:xxx 部分を画像ファイル名で書き換える。

mailparser.pl Perl コード

Perl コードを以下に示す。mailparser 6 のように引数にメールメッセージファイル(番号)を指定して起動する。カレントディレクトリに HTML とそこから参照している jpeg ファイルが生成される。

#!/usr/bin/perl -w
# -*- coding: utf-8; mode: cperl; -*-
# mailparser.pl
# usage: mailparser メールメッセージファイル
# - メールメッセージからHTMLファイル,画像ファイルを生成する
# - HTMLファイル名は実行日付yyyymmdd + メールメッセージ番号 xxxx + .html
# - 画像ファイル名とContent-IDをマルチパートヘッダから取得する
# - HTML 文字コードを iso 2022-jp から UTF-8 に変換する
# - HTML画像srcのContent-ID指定を画像ファイル名指定に書き換える
# $Id: mailparser.pl 20 2015-10-04 15:23:48Z isao $
use strict;
use utf8;
use MIME::Parser;
use Encode;
binmode(STDOUT, ":utf8"); binmode(STDERR, ":utf8");
 
($#ARGV < 0) && die "usage $0 mailmessagefile\n";
 
# MIME::Parser オブジェクト
my $parser = MIME::Parser->new;
$parser->output_to_core(1);      # ファイル自動出力を抑止
my $entity = $parser->parse_open($ARGV[0]);
 
# Subject の取得: MIME::Parser では ISO-2022-JP 扱えず,Encode モジュールでデコード
my $sub = decode('MIME-Header', $entity->head->get('subject'));
$sub =~ s/\s+$//g;  # 末尾の空白・改行を除去
 
# HTMLコンテンツ
my $html = "";
my %imgpair; # 「Contet-ID:imageファイル名」の配列
 
# メールメッセージと現在日付からHTMLファイル名を生成
my ($day, $mon, $year) = (localtime)[3, 4, 5];
$year += 1900; $mon += 1;
my $today = sprintf("%04d%02d%02d", $year, $mon, $day);
my $num = $ARGV[0] + 0;
my $htfn = $today . "-" . sprintf("%04d", $num) . ".html";
 
# parts_DFS メソッドで取り出したパート毎の処理
foreach my $part ($entity->parts_DFS) {
    my $ctype = $part->mime_type;        # MIME Type
    my $body = $part->bodyhandle;        # パートのボディ
    if ($body) {
        my $cont = $body->as_string;     # 内容を取り出して変数に格納(デコード済)
        if ($ctype eq 'text/html') {     # HTML の編集
            # ISO 2022-JP を UTF-8 に変換
            $html = decode("iso-2022-jp", $cont);
            # head に title を挿入
            $html =~ s/<\/HEAD>/<title>$sub<\/title><\/HEAD>/i;
        } elsif ($ctype =~ /image\//) {  # 画像ファイル名等の情報取得
            # パートの header から画像ファイル名と画像IDを取得
            my $head = $part->header;
            my $imgf; my $imgid;
            foreach my $i ($head) {
                for (my $j = 0; defined($i->[$j]); $j++) {
                    if ($i->[$j] =~ /name="([^\.]*)\.([^"]*)"/i) {
                        $imgf = $1 . "." . $2; # 画像ファイル名
                    }
                    if ($i->[$j] =~ /Content-ID: <([^>]*)>/i) {
                        $imgid = $1;          # 画像 Content-ID
                    }
                }
            }
 
            # ファイルに出力
            open(FH, ">", $imgf) || die "can not open $imgf: $!\n";
            binmode FH;
            print FH $cont;
            close(FH);
 
            # 画像 Content-ID とファイル名の対を登録
            $imgpair{$imgid} = $imgf;
        }
    }
}
 
# html中のイメージsrcのIDをファイル名で書き換え
# <img src="cid:content-id"> --> <img src="filename.jpeg">
foreach my $key (keys(%imgpair)) {
    $html =~ s/cid:$key/$imgpair{$key}/gi;
}
open(FH, ">:utf8", $htfn) || die "can not open $htfn: $!\n";
print FH "$html\n";
close(FH);