Later Mail を作る
Hit Me Later にインスパイアされて、日本語メールがちゃんと通る Hit Me Later 互換サービスを作ってみます。
作るもの
(時間)@5-55.jp へメールを投げると、その(時間)後にメールを投げ返してくれるサービス。あわせて、(曜日)@5-55.jp へ投げると、次の(曜日)にメールを投げ返してくれるようにします。
※ いま気づいたんですが、Hit Me Later に「wednesday@hitmelater.com and we'll send it back to you the first Wednesday morning after today.」と書いてあります。朝って何時?
とりあえず、メールを受信した時刻に送り返すようにして、朝に送り返すかどうかは次の課題とします。
方針
- (時間)@5-55.jp、(曜日)@5-55.jp宛てのメールを受信
- (時間)、(曜日)から返信日時を計算して返信用メールを保存
- 保存ディレクトリを定期的に監視して時間がきたメールを送信して削除
実際は、メールの受信と保存が1ステップ、送信(削除)とあわせて2段階の処理になります。
前提
独自ドメインをとってメールサーバを管理している環境なら「すぐできます」ということです。(Postfix じゃなくて qmail や sendmail でも同様のことはできると思いますが、うちの環境が Postfix なので)
第1ステップ:メール受信
まず、エイリアス(/etc/aliases)に受け付けるメールアドレスを列挙します。エイリアスを変更したら「newaliases」でDB更新を忘れずに。
$ e /etc/aliases0: "| ruby /home/hml/latermail.rb" 1: "| ruby /home/hml/latermail.rb" 2: "| ruby /home/hml/latermail.rb" : : 24: "| ruby /home/hml/latermail.rb" sunday: "| ruby /home/hml/latermail.rb" : : saturday: "| ruby /home/hml/latermail.rb"$ s newaliases
ここに書いたアドレスだけ受付が可能になります。通常エイリアスは「0: root」とか書いて「0@5-55.jp を root@5-55.jp へ転送」という風に使うのですが、こうやってスクリプトを書くと、「0@5-55.jp に到着したメールをパイプでコマンドへ渡して実行」することができます。
メール受信と書きましたが、このようにコマンドを指定すると、指定したコマンド内で保存しない限り、メールは書き込まれません(捨てられます)。明示的に捨てる場合は「spam: /dev/null」というようにします。
メール受信時の処理
次に、上で指定した latermail.rb を作ることにします。やりたいことは次の通り。
- 標準入力からメールの内容を読み取り
- ヘッダの内容を書き換えて
- 送信時間を付記したファイルへ保存する
100行ほどの簡単なプログラムなので、このページの最後にソースを貼り付けておきます。(注意:今後プログラムの改変が行われたとしても、ここには反映しません)
詳しくはソースを見てもらえばいいのですが、標準入力から1行ずつ読み込んでヘッダの種別ごとに処理を行って、ファイルに書いています。
メールの送信先
# From mam@example.com Mon Aug 25 17:30:51 2008
when /^From /i
from = line.split(/\s/)[1]
@buf << line
最初の「From」(From:ではありません)は、UNIX From と呼ばれるもので、mailbox 形式(Maildir)で Postfix を運用している場合、メールの先頭にはこの行が挿入されます。(Maildir方式以外の場合にこの行が付くかどうかは確認していません)
ここからメールの送信先(受信先=再配達するときの送信先)を取得します。@buf に読んだ内容を突っ込んでいるのは、まだファイル名が決まらないための処置です。
再配達の時間
# Delivered-To: 4@5-55.jp when /^Delivered-To:/i to = line.split(/[\s@]/)[1] # sendmailがINSERTするので書かない
到着したアドレスを取得してそれを「再配達の時間」とします。Delivered-To: ヘッダは Postfix sendmail が勝手に付けてくれるので、ファイルには書きません。
再配達時刻
# Date: Mon, 25 Aug 2008 17:30:33 +0900
when /^Date:/i
dt = add(line, to)
@buf << "Date: #{dt.to_s}"
def add(dt, to) week = {'sunday'=>0,'monday'=>1,'tuesday'=>2,'wednesday'=>3,'thursday'=>4,'friday'=>5,'saturday'=>6} t = Time.parse(dt) if to =~ /\d/ t += to.to_i * 3600 elsif week[to] t += ((6 - t.wday + week[to]) % 7 + 1) * 3600 * 24 end t end
受信した時刻に「再配達の時間」を足して「再配達時刻」とします。「再配達の時間」が数字だったら 3600秒をかけて、曜日だったら魔法の計算式でその曜日までの日数を計算して 3600秒×24時間をかけています。
これをもしその曜日の朝7時という風にするのであれば、こうしておけば OK でしょう。
t = Time.mktime(t.year, t.month, t.day, 7)
ファイル名
# Message-Id: <20080825083051.76E9B2908D@mail.5-55.jp>
when /^Message-Id:/i
mi = line.split(/[<@>]/)[1]
@buf << line
io = open("#{@dir}#{dt.strftime('%Y%m%d%H%M%S')}.#{mi}", "w")
@buf.each { |bf| io.puts bf }
ファイル名は Message-Id: を使用することにします。Message-Id: はユニークにしなければならないと RFC2822 で規定されているので、これを使っておけばたぶん大丈夫。
このままでは使いづらいので、ファイル名の先頭に「再配達時刻(YYYYMMDDhhmmss)」を付けて、「20080826173033.20080825083051.76E9B2908D」というようなファイル名にしておきます。そうするとファイル名で送信していいかどうかの判断がついて便利です。
この Message-Id: が出てくるまでに読み込んだヘッダは @buf に溜め込んであるので、ファイルをオープンしたら @buf を書き込むようにします。これ以降は @buf には溜めず、そのままファイルへ書き込むことになります。
特殊な処理(To:)
# To: 4@5-55.jp
when /^To:/i
tf = true
@buf << "To: #{from}"
else if tf next if line =~ /^\s/ tf = false end
複数の宛先へ送られたメールが転送されてきた場合、To: が1行で収まりきらず複数行に分かれてしまうことがあります。
To: 10@5-55.jp, 11@5-55.jp, 12@5-55.jp, 13@5-55.jp, 14@5-55.jp, 15@5-55.jp, 16@5-55.jp
これをそのまま「"To: #{from}"」で置き換えてしまうと、再配達されたメールでは、
To: mam@example.com, 15@5-55.jp, 16@5-55.jp
というようになってしまうので(別に実害があるわけじゃないのですが)、それを抑制するために、To: が出てきたらフラグを立てて、先頭が空白の行は継続行なのでこれは無視して、そうでない行が出てきたらフラグを倒すという処理をしています。
特殊な処理(Content-Type:)
# Content-Type: multipart/mixed; boundary="------------Boundary_mp2LDOi"
when /^Content-Type: multipart/i
bd = line.split('"')[1]
@buf << line
while line = gets if bd && line.include?("--#{bd}") if (ct += 1) == 2 io.puts commercial(to) end end end unless bd io.puts commercial(to) end end
メールに添付ファイルが付く場合などは、Content-Type: が multipart になって、メール本文が入れ子のような構造になります。今回の処理では本文はノータッチなのですが、少し色気を出して、本文の最後に少しだけコマーシャルを入れさせてもらうことにしました。
- -
#{ago(to)} by http://lm.5-55.jp
and please try http://2.5-55.jp (通販Go!Go!)
そうするために、本文と添付ファイルの境界がわからないといけないので、Content-Type: の boundary を持っておいて、行が "--" + boundary のものを数えています。最初に出てくるバウンダリが本文の開始で、次のバウンダリが本文の終了(=添付ファイル1の開始)ですから、2つ目のバウンダリを見つけたら、コマーシャルを書くという処理をしています。
添付ファイルの付いていないメールは Content-Type: が multipart ではないので、その場合は一番最後にコマーシャルを書く処理をつけています。
まとめ
長々書きましたが所詮100行程度のソースなので見ていただければ一目瞭然だと思います。
続きは明日(の日付で)書きます。
latermail.rb
修正(8/27 10:12): docomoメールの場合、Message-Id: を Message-ID: として送ってくるため、正規表現の大文字小文字を区別しないオプション(/i)を付けました。また、Unix From が付かないため、Return-Path: を生かすことにしました。
修正(8/27 10:50): 曜日指定の場合、朝7時にメールするように変更しました。
require 'time' require 'nkf' class LaterMail # ファイル名(時刻+Message-Id)が決まるまで、@bufに溜めておく def initialize(mdir = '') @dir = mdir @buf = Array.new end # 0 - 24 --> 指定時間をプラス # sunday - saturday --> 次の指定曜日までの日数をプラス(曜日以外は入ってこないが、入ってきてもプラスしないだけ) def add(dt, to) week = { 'sunday' => 0, 'monday' => 1, 'tuesday' => 2, 'wednesday' => 3, 'thursday' => 4, 'friday' => 5, 'saturday' => 6 } t = Time.parse(dt) if to =~ /\d/ t += to.to_i * 3600 elsif week[to] t += ((6 - t.wday + week[to]) % 7 + 1) * 3600 * 24 t = Time.mktime(t.year, t.month, t.day, 7) end t end def from_message "From: LaterMail" end def ago(to) if to == '0' "reply immediately" elsif to =~ /\d/ "#{to} hours before replying" else "today is #{to}" end end def commercial(to) "\n--------------------------------------------\n" + "#{ago(to)} by http://lm.5-55.jp\n" + "and please try http://2.5-55.jp (#{NKF.nkf('-jW -xm0', '通販Go!Go!')})\n\n" end def write to = '0' bd = nil ct = 0 io = nil tf = false while line = gets if bd && line.include?("--#{bd}") if (ct += 1) == 2 io.puts commercial(to) end end case line when /^From / from = line.split(/\s/)[1] @buf << line when /^Return-Path:/i from = line.split(/[\s<>]/)[1] # 書いてあってもsendmailで無視されるので書かない when /^X-Original-To:/i @buf << "X-Original-To: #{from}" when /^Delivered-To:/i to = line.split(/[\s@]/)[1] # sendmailがINSERTするので書かない when /^Date:/i dt = add(line, to) @buf << "Date: #{dt.to_s}" when /^From:/i @buf << from_message when /^To:/i tf = true @buf << "To: #{from}" when /^Content-Type: multipart/i bd = line.split('"')[1] @buf << line when /^Message-Id:/i mi = line.split(/[<@>]/)[1] @buf << line io = open("#{@dir}#{dt.strftime('%Y%m%d%H%M%S')}.#{mi}", "w") @buf.each { |bf| io.puts bf } else if tf next if line =~ /^\s/ tf = false end if io io.puts line else @buf << line end end end unless bd io.puts commercial(to) end end end if __FILE__ == $0 # cat mail > ruby latermail.rb lmail = LaterMail.new("/home/hml/Maildir/tmp/") lmail.write end