UECジャーナル

開眼☆シェルスクリプト【14】簡易メーラを作る ―メールファイル操作の応用

第13回に引き続き、サーバのMaildirに溜まったメールを扱う。今回は、読み込みのみに対応したメーラを作る。シェルの機能を使い、届いたメールのフィルタリングやCLIでの最低限必要なインタラクティブな操作を実現する。

「束縛するインターフェースは作るな」 ガンカーズのUNIX哲学

既存のCUI系メーラでまず不便なのはgrep(1)が使えないことだ。できればインタラクティブシェルを扱っているのと同じようにメールも扱いたい。

「私は発明が必要の母だと考えない。私のなかでは発明は暇と直接関係していて、多分怠惰とも関連している。面倒を省くという点で」 アガサ・クリスティー

開発範囲

~/Maildir/new/に届いたメールを取り込んで、フィルタのルールに応じて振り分けて受信トレイに置き、vim(1)でメールを読み込み専用で開いて表示し、見たメールを未読トレイから既読トレイに移すといったツールを開発する。Maildir形式に関しては、第12回ないしは第13回を参考されたい。

なお、既読のメールを~/Maildir/new/から~/Maildir/cur/に移すという操作をするので、他のメーラとの併用は考えない。また、Open usp Tukubaiを使うため、それぞれのコマンドについてはマニュアル(plusselfloopjgyodelf)をご覧いただきたい。

メールの取り込み処理

バックエンドで受信したメールをメーラに取り込んで整理する部分を作る。まず、次のようにディレクトリを準備する。

$ tree -L 1 ~/MAILER/
/home/ueda/MAILER/
├── DATA
├── FILTERS
└── TRAY
$ 

これらのディレクトリに対し、「~/Maildir/new/に届いたメールをDATAに取り込んで、FILTERS内のフィルタにマッチしたものをTRAYに置く」という動きをするスクリプトFETCHERを作る。

シェルスクリプトのヘッダ部と、メールを取り込んでDATAにメールを置くところまでを次のように記述する。第一引数に~/Maildir/new/下のファイル名を指定する。なお変換部分では、「ヘッダ部分を加工したもの($tmp-header)および検索・表示のためにUTF-8変換したもの($tmp-utf)を作っている。

#!/bin/sh 
# FETCHER <mailfile>
# written by R. Ueda (USP lab.) Nov. 20, 2012
dir=~/MAILER
mdir=~/Maildir
tmp=~/tmp/$$

# データのUTF8変換、整形済みヘッダ作成#############
nkf -wLux "$mdir/new/$1"                        |
tee $tmp-work                                   |
# ヘッダを作る
sed -n '1,/^$/p'                                |
awk '{if(/^[^ \t]/){print ""};printf("%s",$0)}' |
#最初の空行の除去と最後に改行を付加
tail -n +2                                      |
awk '{print}'                                   > $tmp-header

#ヘッダと本文をくっつける。
sed -n '/^$/,$p' $tmp-work                      |
cat $tmp-header -                               > $tmp-utf

sed(1)で対象となるヘッダを取り出し、awk(1)でヘッダに入っている余計な改行を削除する処理をしている。

次に示すヘッダはTo:に複数のアドレスを指定したものだ。このままでは扱いにくいので、改行を削除して1行にする。改行を削除して1行にしておけば、To:をgrep(1)するだけで全部のアドレスが取得できる。

To: ueda@xxx.jp, r-ueda <r-ueda@yyy.com>, 
	Ryuichi UEDA <ryuichiueda@zzz.com>

↓ 変換

To: ueda@xxx.jp, r-ueda <r-ueda@yyy.com>, Ryuichi UEDA <ryuichiueda@zzz.com>

フィルタを準備

フィルタはただのスクリプトだ。対象を振り分けるかどうかを判定する処理をしているだけのものとしている。まず、「all」という名前で次の極小スクリプトを用意した。allは必ずメーラに準備しておく。

$ cat ./FILTERS/all 
#!/bin/sh
true
$ 

ほかにも次のようなフィルタを用意した。これはFreeBSDのサーバから届くシステム管理用メール、いわゆるCharlie Rootからのメールに反応するフィルタだ。

$ cat ./FILTERS/bsd.example.com
#!/bin/sh
grep -i '^from:' < /dev/stdin 	           |
grep -q -F 'root@bsd.example.com'
$ 

標準入力からメールを読み込んで、条件にマッチしたら終了ステータス0を返すスクリプトを準備しておく。これがフィルタだ。もちろん他のプログラミング言語を使ってもいいし、もっと長いフィルタを作ってもかまわない。

この方法をとっておくと、たとえば優秀なスパムフィルタがあったときに、それをラッピングするシェルスクリプトを書けば利用できるようになる。メーラの方法に束縛されることがない。

フィルタリング

FETCHERの後半部分を次に示す。トレイにはヘッダのファイルを置いて「そのトレイにメールがある」という目印代わりにする。新着のメールは、たとえばフィルタ allに適合したものは./TRAY/all/new/下に置く。既読のメールは./TRAY/all/20121125/というように日付のディレクトリを作って整理する。

# フィルタ #################################
cd "$dir/FILTERS"
# ファイル名のUNIX時間から年月日、時分秒を計算
case $(uname) in
Linux)
        D=$(date +%Y%m%d -d @$(echo $1 | cut -c 1-10))
        T=$(date +%H%M%S -d @$(echo $1 | cut -c 1-10))
        ;;
FreeBSD)
        D=$(date -j -f %s $(echo $1 | cut -c 1-10) +%Y%m%d)
        T=$(date -j -f %s $(echo $1 | cut -c 1-10) +%H%M%S)
        ;;
*)
        exit 1
        ;;
esac
for f in * ; do
        ./$f < $tmp-utf || continue
        mkdir -p $dir/TRAY/$f/new
        cat $tmp-header > $dir/TRAY/$f/new/$D.$T.$1
done
# ファイルを移して終わり ##############
mkdir -p "$dir/DATA/$D"                 &&
cat $tmp-utf > "$dir/DATA/$D/$D.$T.$1"  &&
mv "$mdir/new/$1" "$mdir/cur/$1"

rm -f $tmp-*
exit 0

取り込んだメールやヘッダのファイル名には、整理のために元のメールファイル名の先頭に年月日と時分秒をつけておく。その処理のために、ファイル名のUNIX時間から年月日と時分秒を求めている。第12回で説明したように、メールのファイル名の先頭には10桁で1970年1月1日からの秒数がついており、GNU Core Utilities dateであれは次のように実行することでUNIX時間から日付を出力させることができる。

$ date -d @1234567890
2009年  2月 14日 土曜日 08:31:30 JST
$ 

BSD dateの場合にはフォーマットの%sを使って次のように利用する。

$ date -j -f %s 1234567890
2009年 2月14日 土曜日 08時31分30秒 JST
$ 

文字列の切り取りにはcut(1)コマンドを使っている。cut(1)コマンドは次のように-cで切り出し部分を指定できる。次のように実行すると、文字列の2つめから4つめまでが切り出される。

$ A=12345
$ echo $A | cut -c 2-4
234
$ 

for構文で、フィルタに1つずつUTF8変換したメールを入力していく。continueは、for文のそれ以降の処理をスキップしてforの先頭に戻る指定。フィルタにマッチしたときだけ処理が実行され、フィルタの新着トレイにヘッダのファイルが置かれる。

FETCHERができたので、次のように~/Maildir/new/下のメールを指定して実行する。

$ ./FETCHER 1352657044.Vfc03I468a21M42631.foo1
$ ls ./TRAY/*/new/*.1352657044.Vfc03I468a21M42631.foo1
./TRAY/all/new/20121112.030404.1352657044.Vfc03I468a21M42631.foo1
./TRAY/bsd.example.com/new/20121112.030404.1352657044.Vfc03I468a21M42631.foo1
$ 

このように、各フィルタの新着トレイにメールがあることを確認する。

リーダを作る

リーダ(スクリプト名READER)を作る。まずは冒頭部分を次に示す。READERにはオプションでトレイのパス、メールをリスト表示するときに何件表示するかを指定する。FETCHERを使ってトレイを更新する処理だ。ディレクトリ名を除去したファイルのリストを作り、xargs(1)でFETCHERに1つずつ処理させている。

#!/bin/sh
#
# READER <dir> <num>
# written by R. Ueda (USP lab.) Nov. 20, 2012
tmp=~/tmp/$$
dir=~/MAILER

#先にメールを取得 ###############
echo ~/Maildir/new/*                    |
tr ' ' '\n'                             |
awk '!/\*$/'                            |
sed 's;^..*/;;'                         |
xargs -r -n 1 -P 1 $dir/FETCHER
$ 

ここでは、新着メールがなくてもエラーが発生しないように細工がしてある。まず、新着メールがないと*がそのままパイプに通っていくが、awk(1)で除去している。grep(1)を使うと検索結果の有無で終了ステータスが変わるため、代わりにawk(1)を使っている。xargs(1)は通常、入力が空でもコマンドを一回実行してしまうが、これを-rオプションで抑制している。

次に、メールのリストを表示してメールを選択してもらう部分を記述する。

#メールのリストを作る #######################
cd "${1:-$dir/TRAY/all/new}"

#表示対象ファイルの抽出
echo *                                  |
tr ' ' '\n'                             |
grep -v '\*'                            |
sort                                    |
tail -n "${2:-10}"                      > $tmp-files
[ $(gyo $tmp-files) -eq 0 ] && rm -f $tmp-* && exit 0

#subjectのリストを作成
cat $tmp-files                          |
xargs grep -H -i '^subject:'            |
sed 's/:[Ss]ubject:/ /'                 > $tmp-subject
#1:ファイル 2:subject

#日付のリストを取得し、subjectのリストと連結
cat $tmp-files                          |
xargs grep -H -i '^date:'               |
sed 's/:[Dd]ate:/ /'                    |
#1:ファイル 2~:date
self 1 2 3 4 6                          |
sed 's/:[0-9][0-9]$//'                  |
loopj num=1 - $tmp-subject              |
#1:ファイル名 2~日時、subject
sed -e '1!G;h;$!d'                      |
awk '{print NR,$0}'                     |
#1:リスト番号 2:ファイル名 3~:日時, subject
tee $tmp-list                           |
#リストの表示
delf 2
cd - > /dev/null
printf "どのメールを見るか? (番号) :  "
read n

ここまでの部分を実行すると、次のような出力が出る。

$ ./VIEWER
1 Sun, 25 Nov 07:10 処理エラー
2 Sun, 25 Nov 06:00 【先着3名】怪しいアレが5000円!【怪しい.com】
3 Sun, 25 Nov 04:00 Logwatch for bsd.example.jp (Linux)
4 Sun, 25 Nov 03:04 bsd.example.com security run output
...
10 Sun, 25 Nov 01:00 【再送】本当に致命的なエラー
どのメールを見るか? (番号) :  

番号と着信日時、メールのSubjectが表示され、どの番号のメールを見るか入力を促す

最初に、第一引数で指定されたトレイにカレントディレクトリを移動。cd "${1:-$dir/TRAY/all/new}"は「$1が空ならば$dir/TRAY/all/new」という意味になる。後のtail(1)のオプション指定でもこの方法を使っている。

次にトレイのファイルのリストを作って、リストが空ならそのまま処理を終えるという処理を書いてある。その後のコードは、各メールの受信時刻とSubjectを抽出し、画面に出力するための細かい文字列処理だ。さらにファイル名とSubjectの対応表、ファイル名と時刻の対応表を作り、loopj でこれらの対応表を結合する。あとは、新着順に並び替え番号をつけて$tmp-listに表示する。出力する段階でファイル名をdelfで削っている。

cd -は、前回のcdをする前のディレクトリに戻るためのコマンドで、手で端末を操作するときにもよく使う。

最後は番号を入力するようにユーザに促し、read nで番号を受け付けている。端末からユーザが打った数字(正確には任意の文字列)が変数nに代入される。nに値が代入されたあとは、次のように処理を進める。

#メールを表示 ###################################
f=$(awk -v n="$n" '$1==n{print $2}' $tmp-list)
m="$dir/DATA/$(echo $f | cut -c 1-8)/$f"
[ -f "$m" ]						&&
grep -E -i '^(from|to|cc|date|subject):' $m > $tmp-work &&
sed -n '/^$/,$p' $m >> $tmp-work			&&
view $tmp-work
#既読トレイに移す (newの中だけ)  #################
for t in $dir/TRAY/* ; do
        [ -e "$t/new/$f" ] || continue
        mkdir -p $t/$(echo $f | cut -c 1-8)
        mv -f $t/new/$f $t/$(echo $f | cut -c 1-8)/$f
done

rm -f $tmp-*
exit 0

変数nからファイル名を抽出している。あとはメールから必要なヘッダとメールの文を取り出して、view で開いている。

viewは単にvimをリードオンリーで開くためだけのコマンドだ。普段のvimの使い方でメールが読める。また、見ているファイルを別のディレクトリにそのまま保存できるなど、vimユーザには便利なメールリーダといえる。

viewを正常に閉じるとフィルタの新着トレイから、読んだメールを日付別の既読トレイに移動する。既読のトレイを開いた場合は、特に何も起こらない。この処理は、各フィルタのトレイ全部に対して行う。

おわりに

今回はシェルスクリプトでメールリーダを作った。 返信機能を付けるとすると、view で保存したメールを処理し、返信用のメールの雛形を作るスクリプトを作ることになる。メールはmailコマンドで送ればよいし、メールアドレスの入力が面倒ならvimの補完ツールの利用や、メールアドレスを提示するコマンドを作ればなんとかなるだろう。「何件メールがトレイにあるか」などは、ls(1)とwc(1)を使えば実施できる。

Software Design 2013年2月号 上田隆一著、「テキストデータならお手のもの 開眼シェルスクリプト 【14】簡易メーラーを作る ―メールファイル操作の応用」より加筆修正後転載

Last modified: 2014-03-17 00:00:00