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

Pocket
LINEで送る

出典: 技術評論社SoftwareDesign

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

 前回に引き続き、サーバのMaildirに溜まったメールをいじります。
今回は、メーラー(ただしリードオンリー)を作ってみます。
このなかで、シェルの機能を使い、
届いたメールのフィルタリングやCLIでの
最低限必要なインタラクティブな操作を実現します。

 この企てを考えついたのは、
仕事中にいちいちブラウザやGUIメーラーを開くのが面倒だと思ったからです。
CUIのメーラーは便利なものがいろいろあるのに
リードオンリーのものを作ってどうするんだという話ですが、
筆者としては、既存のものではこれがちょっと気になります。

  • 束縛するインターフェースは作るな。–ガンカーズのUNIX哲学から

メーラーに入り、エディタのような画面が開いてプロンプトの「$」
が消えてしまった瞬間、我々はgrepが使えないことを覚悟させられます。
メールなんてせいぜいお客さんの名前で検索をかけて、
あとは見ないで捨てますので(脚注:本当のような、嘘のような・・・)、
これは困ります。
プロンプトが消えるのはエディタもそうですが、エディタと違って
たかがメールリーダーでプロを目指す気が筆者に微塵もありません。

 ということで、怠け癖が極限に達すると人はこんな
シェルスクリプトを書くという例をお見せしたいと思います。
そういやこんな言葉もあったなということで、以下の名言を。

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

14.1. 何をどこまで作るか

14.1.1. 作るもの

 今回は Maildirnew に届いたメールを取り込んで、

  • フィルタのルールに応じて振り分けて受信トレイに置き、
  • vim でメールを読み込み専用で開いて表示し、
  • 見たメールを未読トレイから既読トレイに移す

ツールを作ります。Maildir については、
前号、前々号で説明していますが、
要はメールアカウントの ~/maildir/new/ というディレクトリに
1メール1ファイルでメールが届く方式のことです。

14.1.2. 環境

 今回は、メールの届くサーバにメーラーを作り込みます。
サーバはVPS上で動いています。リスト1に環境を示します。

リスト1: 環境

1
2
3
4
5
6
7
8
[ueda@mail ~]$ cat /etc/redhat-release
CentOS release 6.3 (Final)
[ueda@mail ~]$ uname -a
Linux mail.usptomonokai.jp 2.6.32-279.5.2.el6.x86_64 #1 SMP
 Fri Aug 24 01:07:11 UTC 2012 x86_64 x86_64 x86_64 GNU/Linux
[ueda@mail ~]$ bash --version
GNU bash, version 4.1.2(1)-release (x86_64-redhat-linux-gnu)
(割愛)

14.1.3. 制限等

このサーバでは SMTP サーバが動いており、
Maildir 方式で各アカウントにメールを配信しています。
今回は、既読のメールを ~/Maildir/new/ から ~/Maildir/cur/
に移すという操作をしますので、他のメーラーとの併用は考えません。

 今回はこのマシンに直接メーラーを作っていきますが、
自分のノートPCなどリモートから使うメーラーを作ることも可能です。
このときは、スクリプトで使うコマンドを ssh
でリモートから動かせるようにします。
今回はそれをやると解説するにはコードが長くなるのでやめておきます。

 また、最近では単なるHTML形式を越えたグラフィカルなメールがありますが、
そういうのを見るのは諦めます。
大抵の場合、その手のメールは筆者にとって重要ではありません。

 最後にお断りですが、今回はやることが多いので、
Tukubaiのコマンドについて説明していません。
plus, self, loopj, gyo, delf がTukubaiのコマンドです。
https://uec.usp-lab.com で機能をお調べ下さい。

14.2. メールの取り込み処理

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

リスト2: ディレクトリ構成

1
2
3
4
5
[ueda@mail ~]$ tree -L 1 ~/MAILER/
/home/ueda/MAILER/
├── DATA
├── FILTERS
└── TRAY
  • DATA: 前処理をしたメールを整理して置く場所
  • FILTERS: メールを振り分ける条件を書いたスクリプトの置き場所
  • TRAY: 受信トレイ

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

 まず、シェルスクリプトのヘッダ部と、
メールを取り込んで DATA にメールを置くところまでをリスト3のように記述します。
$1~/Maildir/new/ 下のファイル名を指定します。
8~12行目がエラーチェック関数、
13行目がメールがあるかどうかのチェック、
それ以降がメールを扱いやすいように変換する部分です。

 変換部分では、

  • ヘッダ部分を加工したもの( $tmp-header
  • 検索・表示のためにUTF-8変換したもの( $tmp-utf

を作っています。

リスト3: FETCHER の前半部分

 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
#!/bin/bash
# FETCHER <mailfile>
# written by R. Ueda (USP lab.) Nov. 20, 2012
dir=~/MAILER
mdir=~/Maildir
tmp=~/tmp/$$

ERROR_CHECK(){
        [ "$(plus ${PIPESTATUS[@]})" -eq 0 ] && return
        rm -f $tmp-*
        exit 1
}
[ -f "$mdir/new/$1" ] ; ERROR_CHECK

# データの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
ERROR_CHECK
#ヘッダと本文をくっつける。
sed -n '/^$/,$p' $tmp-work      |
cat $tmp-header - > $tmp-utf
ERROR_CHECK

 ヘッダの加工では、19行目の sed で取り出し、
20行目の awk で、ヘッダに入っている余計な改行を取る処理をしています。
リスト4は、To: に複数のアドレスが指定されているヘッダの例ですが、
こうやって改行をとっておけば To: をgrepするだけで全部のアドレスが取得できます。
22行目の awk は、最終行に改行が抜けたテキストに改行を付ける常套手段です。

リスト4: ヘッダの改行を戻す

1
2
3
4
5
6
#before
To: ueda@xxx.jp, r-ueda <r-ueda@yyy.com>,
        Ryuichi UEDA <ryuichiueda@zzz.com>

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

  ERROR_CHECK はコマンドやパイプラインの終了ステータスを監視し、
エラーがあったら処理を止める関数です。
13行目の、「指定したファイルが Maildir にあるか」のチェックは、
DATA ディレクトリ内を汚さないために必須です。

14.2.1. フィルタを準備

 後半部分を示す前に、このメーラーで作る「フィルタ」をお見せします。
まず、「all」という名前でリスト5の極小スクリプトを用意しました。
allは必ずこのメーラーに準備しておきます。

リスト5: 全部受理する all フィルタ

1
2
3
[ueda@mail MAILER]$ cat ./FILTERS/all
#!/bin/bash
true

他にも、リスト6のようなものを用意しました。
これは、とあるFreeBSDのサーバから届くシステム管理用メールに反応するフィルタです。

リスト6: rootからのメールかどうか調べるフィルタ

1
2
3
4
[ueda@mail MAILER]$ cat ./FILTERS/bsd.usptomo.com
#!/bin/bash
grep -i '^from:' < /dev/stdin   |
grep -q -F 'root@bsd.usptomo.com'

 このように、標準入力からメールを読み込んで、
条件にマッチしたら終了ステータス 0
を返すスクリプトを準備しておきます。
もちろん、他の言語を使ってもいいですし、
もっと長いフィルタを作っても構いません。

 この方法をとっておくと、例えば優秀なスパムフィルタがあったときに、
それをラッパーするシェルスクリプトを書けばそれを利用できるので、
メーラーの方法に束縛されることがなくなります。
執筆にあたってスパムフィルタについては何も調査してませんが、
何も心配してません。まさにUNIX哲学。

14.2.2. フィルタリング

 では、 FETCHER の後半部分をリスト7に示します。
12行目まででメールのヘッダをフィルタごとの新着トレイに置いて、
万事うまくいったら残りのファイル処理を確定しています。

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

リスト7: FETCHER の後半部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# フィルタ #################################
cd "$dir/FILTERS" && [ -e "all" ] ; ERROR_CHECK
# ファイル名のUNIX時間から年月日、時分秒を計算
D=$(date +%Y%m%d -d "@"${1:0:10}) ; ERROR_CHECK
T=$(date +%H%M%S -d "@"${1:0:10}) ; ERROR_CHECK

for f in * ; do
        ./$f < $tmp-utf || continue
        mkdir -p $dir/TRAY/$f/new
        cat $tmp-header > $dir/TRAY/$f/new/$D.$T.$1
        ERROR_CHECK
done
# ファイルを移して終わり ##############
mkdir -p "$dir/DATA/$D"                 &&
cat $tmp-utf > "$dir/DATA/$D/$D.$T.$1"  &&
mv "$mdir/new/$1" "$mdir/cur/$1"
ERROR_CHECK

rm -f $tmp-*
exit 0

 取り込んだメールやヘッダのファイル名には、整理のため、
もとのメールファイル名の頭に年月日と時分秒をつけておきます。
その処理のために、4,5行目でファイル名のUNIX時間から年月日、
時分秒を求めています。前々号で説明したように、
メールのファイル名の先頭には10桁で1970年1月1日からの秒数がついており、

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

のように、 date コマンドで変換できます。
${1:0:10} は、 $1 の先頭から10文字という意味です。
次のように、任意の変数に対して使えます。

1
2
3
4
$ A=12345
#2文字目(0から数えて1文字目、から3文字)
$ echo ${A:1:3}
234

 7~12行目のfor文で、フィルタに一つずつ、
UTF8変換したメールを入力していきます。
continue は、for文のそれ以降の文をスキップするコマンドです。
フィルタにマッチしたときだけ、13行以降の処理が行われ、
フィルタの新着トレイにヘッダのファイルが置かれます。

 14~16行目はかなり変な書き方をしていますが、
これは ERROR_CHECK をいちいち書くのを避ける小技です。
コマンドを全部 && でつないで、
どれか一つが失敗したらそこで終わって ERROR_CHECK
に処理が飛び、 exit 1 します。

  FETCHER ができたので、リスト8のように
~/Maildir/new/ 下のメールを指定して実行してみます。

リスト8: FETCHER の実行

1
2
3
4
[ueda@mail MAILER]$ ./FETCHER 1352657044.Vfc03I468a21M42631.hoge1
[ueda@mail MAILER]$ ls ./TRAY/*/new/*.1352657044.Vfc03I468a21M42631.hoge1
./TRAY/all/new/20121112.030404.1352657044.Vfc03I468a21M42631.hoge1
./TRAY/bsd.usptomo.com/new/20121112.030404.1352657044.Vfc03I468a21M42631.hoge1

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

14.3. リーダーを作る

 では、リーダー(スクリプト名: READER )を作っていきましょう。
まずは冒頭部分をリスト9に示します。
READER にはオプションでトレイのパス、
メールをリスト表示するときに何件表示するかを指定します。

 14~19行目は、さっき作った FETCHER
を使ってトレイを更新する処理です。
最初の4行でディレクトリ名を除去したファイルのリストを作り、
xargsFETCHER に一つずつ処理させています。

リスト9: READER のヘッダ部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
#
# READER <dir> <num>
# written by R. Ueda (USP lab.) Nov. 20, 2012
tmp=~/tmp/$$
dir=~/MAILER

ERROR_CHECK(){
        [ "$(plus ${PIPESTATUS[@]})" -eq 0 ] && return
        rm -f $tmp-*
        exit 1
}
#先にメールを取得 ###############
echo ~/Maildir/new/*    |
tr ' ' '\n'             |
awk '!/\*$/'            |
sed 's;^..*/;;'         |
xargs -r -n 1 -P 1 $dir/FETCHER
ERROR_CHECK

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

  Maildir/new/ に何百もメールがあると、
この部分は当然時間がかかります。しかし、
こういう場合は別の端末から FETCHER を起動しておけばよいので、
気を効かせることはやめましょう。
これは、CUI信奉者が自分で使うものですので・・・。

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

リスト10: READER のインタラクション部分

 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
#メールのリストを作る #######################
cd "${1:-$dir/TRAY/all/new}" ; ERROR_CHECK

#表示対象ファイルの抽出
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
ERROR_CHECK

#日付のリストを取得し、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
tac                             |
awk '{print NR,$0}'             |
#1:リスト番号 2:ファイル名 3~:日時, subject
tee $tmp-list                   |
#リストの表示
delf 2

cd - > /dev/null
echo -n "どのメールを見ますか?(番号):  "
read n

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

リスト11: READER のインタラクション出力

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

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

 では、リスト10のスクリプトを見ていきましょう。
まず2行目で、 $1 で指定されたトレイに移動しています。
cd "${1:-$dir/TRAY/all/new}" とありますが、
これは、「 $1 が空ならば $dir/TRAY/all/new
という意味になります。
9行目の tail のオプション指定でもこの方法を使っています。

 4~10行目は、トレイのファイルのリストを作って、
リストが空ならそのまま処理を終えるという処理が書いてあります。
その後のコードは、各メールの受信時刻とSubjectを抽出し、
画面に出力するための細かい文字列処理です。

  • 12~17行目: ファイル名と Subject の対応表
  • 19~25行目: ファイル名と時刻の対応表

を作っています。26行目の loopj で、これらの対応表をくっつけます。
あとは、新着順に並び替え、番号をつけて $tmp-list
に表示します。33行目で画面に出力しますが、
このときはファイル名を delf で削ります。

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

 36,37行目では、番号を入力するようにユーザに促し、
read n で番号を受け付けています。
端末からユーザが打った数字(正確には任意の文字列)が
変数 n に代入されます。

 最後、リスト12に残りの部分を。まず、
2行目で入力してもらった番号からファイル名を抽出しています。
ここで n に変な文字列が入っていると、
4行目でファイルがないので弾かれます。
あとはメールから必要なヘッダとメールの文を取り出して、
view で開いています。

  view は単にvimをリードオンリーで開くためだけのコマンドです。
vimでファイルを読むので、私の場合は普段のvimの使い方でメールが読めます。
また、見ているファイルを別のディレクトリにそのまま保存できるなど、
筆者と全国1000万人のvimユーザには異常に便利なメールリーダになります。

リスト12: READER の後半部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#メールを表示 ###################################
f=$(awk -v n="$n" '$1==n{print $2}' $tmp-list)
m="$dir/DATA/${f:0:8}/$f"
[ -f "$m" ]                                             &&
grep -E -i '^(from|to|cc|date|subject):' $m > $tmp-work &&
sed -n '/^$/,$p' $m >> $tmp-work                        &&
view $tmp-work
ERROR_CHECK
#既読トレイに移す(newの中だけ) #################
for t in $dir/TRAY/* ; do
        [ -e "$t/new/$f" ] || continue
        mkdir -p $t/${f:0:8}
        mv -f $t/new/$f $t/${f:0:8}/$f
        ERROR_CHECK
done

rm -f $tmp-*
exit 0

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

14.4. おわりに

 今回は、シェルスクリプトでメールリーダーを作ってみました。
今後真面目に作り込むと便利になるかもしれません。

 返信機能を付けるとすると、おそらく view で保存したメールを処理し、
返信用のメールの雛形を作るスクリプトを作ることになります。
メールは mail コマンドか何かで送ればよいですし、
メールアドレスの入力が面倒なら vim の補完ツールの利用や、
メールアドレスを提示するコマンドを作ればなんとかなるでしょう。

 また、「何件メールがトレイにあるか」などは、それこそ
lsとwcを使えば事足ります。captiveでないので、なんとかなります。

 今回は正直言いまして、
かなりエクストリームなプログラミングになってしまいましたので、
次回からはもうちょっとマイルドな話題を扱いたいと思います。

Pocket
LINEで送る