上田ブログ

なお、このサイトはbashでできている。

お知らせ: 詳解確率ロボティクス増刷 /  シェルスクリプト高速開発手法入門改訂2版発売中 /  エンジニアHubで記事を書きました / 

開眼シェルスクリプト2013年1月号

Tue Dec 9 22:17:35 JST 2014 (modified: Sun Oct 1 10:50:27 JST 2017)
views: 498, keywords:

  このエントリーをはてなブックマークに追加 
   

出典: 技術評論社SoftwareDesign

13. 開眼シェルスクリプト 第13回メールを操る(2)

13.1. はじめに

 今回は前回に引き続きメールをシェルスクリプトでさばいていきます。 今回の内容は、CUI端末やシェルスクリプトで たくさんのファイルを操作するための小技、 大技が入り乱れてますので、メールなんぞ興味無いという方も注目です。 おさえておかないと、 マウスで数千のファイルをプチプチマウスで操作するハメになりますよ!!

一重積んでは父の為 二重積んでは母の為... (脚注:賽の河原地蔵和讃より。眠れなくなるので知らない人は調べない方がよいです。)

13.2. おさらい

 前回は、 Maildir にたまったメールを日別にディレクトリに整理するという課題を扱いました。 リスト1のように、ホーム( /home/ueda )下の MAIL というディレクトリに日別にディレクトリを作り、 各ディレクトリの下にメールを置きました。 また、メールをUTF-8に変換したものも作り、 ディレクトリ <日付>.utf8 に置きました。

↓リスト1: ~/MAIL/

 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
#MAIL/の下には日付のディレクトリ :~/MAIL$ ls 20120610 20120610.utf8 20120611 20120611.utf8 ... #日付のディレクトリには、それぞれのメールが置かれる :~/MAIL$ ls 20120610/ | head -n 3 1339304183.Vfc03I46017dM943925.abc 1339305265.Vfc03I46062cM458553.abc 1339306807.Vfc03I4607c6M993984.abc #<日付>.utf8 には、UTF-8化した同名のファイルがある :~/MAIL$ ls 20120610.utf8/ | head -n 3 1339304183.Vfc03I46017dM943925.abc 1339305265.Vfc03I46062cM458553.abc 1339306807.Vfc03I4607c6M993984.abc 

 今回はこの状態から、条件抽出してメールを振り分ける方法、 添付ファイルを抜き出す方法を扱います。

13.3. メールの振り分け

 このメールアドレスには世界中から雑多な情報が送られていますが、 ここから条件を満たすメールを集めてみましょう。

 例として、サーバ管理者ならお馴染みのLogwatchからのメールを抽出し、 特定のディレクトリに置くという操作をしてみましょう。 Logwatchは、CentOSなどをインストールすると、 特に設定をしなくてもroot宛にサーバ監視結果のメールを毎日送ってくるツールです。 Logwatchから送られてくるメールは、リスト2のような書き出しで始まります。 見たことある人も多いでしょう。

↓リスト2: Logwatchからのメール

1 2 3
 ################### Logwatch 7.3 (03/24/06) #################### Processing Initiated: Sun Oct 14 04:00:02 2012 ... 

 このメールの送信メールアドレスは「 From: logwatch@<サーバ名> 」 となっており、各メールのヘッダに書いてあります。 蛇足ですが、メーラーはメールのヘッダを読み込んで、 Subject:From: などの行を読んで件名や送信者をGUI出力しているだけで、 メールはあくまで単なるテキストです。

 メールを振り分けるには From: logwatch@... の行をgrepで抽出して、 grepの出力するファイル名を使ってファイルをどこかにコピーすればよいでしょう。 例として、ホスト名をオプションに指定したら、 LOGWATCH_<ホスト名> というディレクトリに当該ファイルをコピーするシェルスクリプトを次に示します。

↓リスト3: 指定したホストのLogwatchからのメールを振り分けるシェルスクリプト

 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
#!/bin/bash -vx # # LOGWATCH: 指定したホストのlogwatchメールを収集 # usage: ./LOGWATCH <hostname> # # written by R. Ueda ()

[ "$1" = "" ] && exit 1

server="$1" dir=/home/ueda/MAIL dest="$dir/LOGWATCH_$server"

cd "$dir" || exit 1 mkdir -p "$dest" || exit 1

echo ????????.utf8/* | xargs grep -F "From: | awk -F: '{print $1,substr($1,1,8)}' | #1:ファイル名 2:日付 awk -v d="$dest" '{print $1,d "/" $2}' | #1:コピー元 2:コピー先 xargs -n 2 cp

 8行目から12行目で、 引数をチェックしたり保存先のディレクトリを作ったりしています。 &&|| については以前から何回か出てきていますが、 && は左側のコマンドが成功(終了ステータスが0) だったら右側のコマンドを実行します。 || はこの逆です。

 15行目の mkdir-p オプションは、 既にディレクトリがあってもエラーにならないように指定しています。 一方で、パーミッション等の理由でディレクトリが作れないときは しっかりエラーを出してくれます。

 17行目から23行目の処理を一言で言うと、 全メールに対してSubjectを調べて、 $1 で指定したホストのLogwatchなら、 ディレクトリ LOGWATCH_<ホスト名> にファイルをコピーしています。 Logwatchのメールは一日一通来るので、 コピーしたファイル名を日付にしています。

 18行目のgrepのオプション -F ですが、 これは正規表現を使わないときに指定するオプションです。 メールアドレスにドット( . )が入っていて、 そのままgrepすると「任意の一字」を示す記号扱いされてしまうので、 -F を指定しました。 grep -F と同義の fgrep というコマンドもあります。

 21行目のawkを通った後の文字列をリスト4に示します。 これを23行目のxargsに通すことでリストの1列目のファイルが 二列目のファイル名でコピーされます。

↓リスト4: 21行目のパイプを通る文字列

1 2
20120611.utf8/1339354818.xyz.abc /home/ueda/MAIL/LOGWATCH_abc.usptomonokai.jp/20120611 20120612.utf8/1339441214.xyz.abc /home/ueda/MAIL/LOGWATCH_abc.usptomonokai.jp/20120612 

リスト4の「xyz」はもっと長い文字列ですが、 紙面で煩わしいので短縮しています。 以後も「xyz」で置き換えます。

 では、実行して、ちゃんと動いたか確かめてみましょう。 リスト5に実行例と結果を示します。

↓リスト5: LOGWATCH の実行

1 2 3 4 5 6 7 8
:~/MAIL$ ./LOGWATCH abc.usptomonokai.jp 2> /dev/null :~/MAIL$ ls LOGWATCH_abc.usptomonokai.jp | head -n 3 20120611 20120612 20120613 :~/MAIL$ grep "^From:" ./LOGWATCH_abc.usptomonokai.jp/* | head -n 2 ./LOGWATCH_abc.usptomonokai.jp/20120611:From:  ./LOGWATCH_abc.usptomonokai.jp/20120612:From:  

 もし複数のサーバからLogwatchのメールを受け取っているならば、 ホストのリストを作ってシェルスクリプト LOGWATCH を繰り返し適用すれば、Logwatchのメールを振り分けることができるでしょう。

13.4. 添付ファイルを抽出する

 次は大技です。メールから添付ファイルを抽出します。 図1は、準備したサンプルメールをgmailで見たところです。 サンプルメールのメールには画像ファイル (イラストと大きなデジカメ写真) が二つ添付されています。

_images/MAIL1.png

図1: サンプルメール(添付ファイル2個付き)

 毎度のこと大雑把なので詳しくは別の資料を見ていただきたいのですが、 添付ファイルがあるときのメールのフォーマットについて説明します。 まず図1のメールについて、 実物(つまりテキストファイル)を見てみましょう。 lessで見るとリスト6のような構造になっているのが分かります。 と言っても、7万7千行もあるので見るのは大変ですが・・・。

↓リスト6: サンプルメール実物(大幅に省略)

 1 2 3 4 5 6 7 8 9 10 11 12 13 14
:~/MAIL$ less ./20121016/1350369599.xyz.abc (ヘッダ。略) Content-Type: multipart/mixed; boundary=047d7b621ee6cf83c604cc276bb3

--047d7b621ee6cf83c604cc276bb3 (メール本文。文字化け) --047d7b621ee6cf83c604cc276bb3 ...ひたすら記号が続く... --047d7b621ee6cf83c604cc276bb3 ...ひたすら記号が続く... --047d7b621ee6cf83c604cc276bb3-- #7万7千行もある。 :~/MAIL$ wc -l ./20121016/1350369599.xyz.abc 77342 ./20121016/1350369599.xyz.abc

 このテキストの中に、 --047d7b621ee6cf83c604cc276bb3 という行がいくつかあって、 どうやら区切り文字になっているようです。

 これは、「MIMEマルチパート」と呼ばれる形式です。 MIMEマルチパートにはいくつか種類がありますが、 1個以上の添付ファイルが含まれたテキスト形式のメールは、 何か特殊な状況でなければ multipart/mixed という種類になります。 今回はこいつだけ相手にしましょう。

 添付ファイルをメールから抽出するには、 boundary で指定された文字列(境界文字列) で挟まれた領域から中身を抽出します。 リスト7は、 CHINJYU.JPG に関係する部分です。

↓リスト7: 境界と境界の間のテキスト

 1 2 3 4 5 6 7 8 9 10 11 12
--047d7b621ee6cf83c604cc276bb3 Content-Type: image/jpeg; name="CHINJYU.JPG" Content-Disposition: attachment; filename="CHINJYU.JPG" Content-Transfer-Encoding: base64 X-Attachment-Id: f_h8cn3pxc0

/9j/4AAQSkZJRgABAQEASABIAAD//gATQ3JlYXRlZCB3aXRoIEdJTVD/2wBDAAEBAQEBAQEBAQEB AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBD (略) 0000000000000000000000000000000000000000000000000000000000000000000000000000 0000000000000000000000000000001//9k= --047d7b621ee6cf83c604cc276bb3

この部分は空行をはさんで上側にファイルの情報が書かれたヘッダ、 下側にエンコードされたファイルの中身があります。 Content-Transfer-Encoding: base64 とあるように、 base64という方式でエンコードされています。 データをbase64でエンコードしたりデコードしたりするのは簡単で、 リスト8のように base64 というコマンドを使います。

↓リスト8: base64コマンドによるエンコードとデコード

1 2 3 4
$ echo あはははは | base64 44GC44Gv44Gv44Gv44GvCg== $ echo あはははは | base64 | base64 -d あはははは 

 では、理屈と方法が分かったので、 添付ファイルを抽出します。 リスト9に作ったシェルスクリプトを示します。 このシェルスクリプトで、 /home/ueda/MAIL/FILES 内に、 <メールファイル名>_<添付ファイル名> で添付ファイルが抽出されます。ディレクトリ /home/ueda/MAIL/FILES は事前に作っておきます。

↓リスト9: 添付ファイル抽出シェルスクリプト

 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
#!/bin/bash # # EXTFILE: メールから添付ファイルを抽出する。 # usage: EXTFILE <電子メールファイル> # written by R. Ueda () Oct. 16, 2012

[ "$1" = "" ] && exit 1 tmp=/home/ueda/tmp/$$ dest=/home/ueda/MAIL/FILES ############################################## #境界文字列を抽出 grep -i '^Content-Type:' "$1" | grep "multipart/mixed" | #最初にあるもの(=ヘッダにあるもの)だけ処理 head -n 1 | sed 's/..*boundary=//' | #「"」がくっついている場合があるので、取って変数に入れる tr -d '"' > $tmp-boundary

############################################## #境界でファイルを分割 awk -v b="^--$(cat $tmp-boundary)" -v f="$tmp-F" \ '{if($0~b){a++};print > f a}' "$1"

############################################## #分割したファイルから添付ファイルを作る grep -i '^content-disposition:' $tmp-F* | #1:grepの結果から中間ファイル名と添付ファイル名を抜き出す sed 's/^\([^:][^:]\):..filename=\(..*\)/\1 \2/' | #1:中間ファイル名 2:添付ファイル名 tr -d '"' | while read a b ; do #抽出、デコード、出力 sed -n '/^$/,$p' "$a" | base64 -d > "$dest/$(basename $1)_${b}" done

#作ったファイルを表示 ls $dest/$(basename $1)_*

rm -f $tmp- exit 0

 10行目~18行目は、 Content-Type: multipart/mixed の行から境界文字列を取り出しています。 この部分は取り出せればどのように書いてもよいのですが、 このスクリプトでは、本文中に Content-Type: multipart/mixed ... と書いてあっても騙されないように一工夫しています。 また、 Content-Type: の大文字小文字が間違っていてもよいように grep-i オプションをつけています。 解説は割愛しますが、 Content-Type の大文字小文字入り乱れの様子は、 リスト10のように端末で確かめることができます。 (sm2, countは open usp Tukubaiのコマンドです。)

↓リスト10: Content-Typeの大文字小文字バリエーション

1 2 3 4 5
:~/MAIL$ grep -i "^content-type:" ./.utf8/* | awk -F: '{print $2}' | count 1 1 | sort | sm2 1 1 2 2 Content-Type 41367 Content-type 75 content-type 9 

 22,23行目のawkは、メールファイルを境界で切って保存する処理です。 このawkにはいろいろポイントがあります。 正直言って、ややこしいです。

 まず、awkの -v オプションは何回か紹介していますが、 bashの変数をawkの変数に事前に代入するためのものです。 ここでは、境界の文字列と切り出し先のファイル名の一部を、 それぞれ bf という変数に代入しています。

 if文中の $0~b は、変数 b を正規表現扱いして、 $0 (行全体)と比較する式です。 変数を右側に持ってくるときは、 / は不要です。

 そして、知らない人には一番わけがわからない print > f a ですが、 実は > は不等号ではなくリダイレクトです。 print で行全体を出力し、その出力先を f a にしています。 f はファイル名の一部 ( /tmp/<プロセス番号>-Fa は境界文字列が見つかると一つずつ増える数字です。 awkでは文字列と数字を並べるとそのまま文字列として連結するので、 リダイレクト先は、 /tmp/<プロセス番号>-F<数字> となります。

 25~36行目は、分割されたファイルから添付ファイルを復元する処理です。 27行目のgrepで Content-Disposition の行 (添付ファイル名が含まれる)を抽出します。 図1のメールを通すと、 27行目のgrepの後ろのパイプにはリスト11の文字列が流れます。

↓リスト11: リスト9、27行目のパイプを通る文字列

1 2
/home/ueda/tmp/3560-F2:Content-Disposition: attachment; filename="CHINJYU.JPG" /home/ueda/tmp/3560-F3:Content-Disposition: attachment; filename="IMG_0965.JPG" 

これを見ると 3560-F03560-F1 はどこにいったということになりますが、 3560-F0 はメールのヘッダ、 3560-F1 は本文で Content-Disposition という文字列がないのでこの時点で弾かれます。 もし Content-Disposition で始まる行があれば添付ファイル扱いされますが、 まあ、ゴミが出るだけなのでよいとしましょう。 もし気になるのであれば、 while 文のなかでチェックします。

 リスト9、29行目のsedは、 grepの出力から分割したファイル名と添付ファイル名を抽出しています。 こうすることで、後ろの while 文に入出力するファイル名を与えています。

 whileの中は、34行目のsedでファイルの中身部分を取り出し、 35行目のbase64で添付ファイルを復元しています。 sed -n '/^$/,$p' は、「空行以降をプリントせよ」という意味になります。 sed -n '<開始行>,<終了行>p' で、 ファイルからある範囲を行単位で出力する処理ができるので、 これは丸暗記しておくとよいでしょう。34行目のように、 行の指定には正規表現や最終行を表す $ などの記号が使えます。

 35行目のbase64で気になるのは、 ちゃんと1ビットも違わずファイルを復元してくれるのかというところですが、 これは大丈夫です。 EXTFILE を実行して、 できたファイルを添付した元のファイルと比較してみましょう。

↓リスト12: EXTFILE の実行と添付ファイルのチェック

 1 2 3 4 5 6 7 8 9 10 11
:~/MAIL$ ./EXTFILE ./20121016/1350369599.xyz.abc /home/ueda/MAIL/FILES/1350369599.xyz.abc_CHINJYU.JPG /home/ueda/MAIL/FILES/1350369599.xyz.abc_IMG_0965.JPG #元のファイルと比較 #バイナリファイル(テキストも)を比較するときは、diffではなくcmpを使います。 :~/MAIL$ cmp ./CHINJYU.JPG ./FILES/1350369599.xyz.abc_CHINJYU.JPG :~/MAIL$ echo $? 0 :~/MAIL$ cmp ./IMG_0965.JPG ./FILES/1350369599.xyz.abc_IMG_0965.JPG :~/MAIL$ echo $? 0 

大丈夫ですね。

13.5. 終わりに

 今回は前回に引き続き、電子メールを扱いました。 気づいた人は少ないと思いますが、 grepを起点としてファイルを操作するためのリストを作るという処理が、 メールの振り分け、添付ファイルの操作の両方で出てきました。 これは覚えておくと便利なテクニックです。 慣れておくと、実際にファイルを操作する直前まではテキスト処理になるので、 whileのなかでcpやmvの前処理をするよりもデバッグが楽になります。 また、立ち上がるコマンドの数も減らすことができます。

 添付ファイルの抽出では、バイナリデータを扱いました。 これは知らない人が意外に多いのですが、 バイナリデータに対してリダイレクトやcatをしても、 データが壊れることはありません。 base64など、テキストとバイナリを橋渡しするコマンドがあれば、 シームレスにバイナリをシェルスクリプトで扱うことができます。 これは次々回あたりに扱ってみたいと考えています。

 次回は、これまでの応用で「CUIおれおれメーラー」 でも作ってみたいと思います。