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.」と書いてあります。って何時?
とりあえず、メールを受信した時刻に送り返すようにして、朝に送り返すかどうかは次の課題とします。

方針

  1. (時間)@5-55.jp、(曜日)@5-55.jp宛てのメールを受信
  2. (時間)、(曜日)から返信日時を計算して返信用メールを保存
  3. 保存ディレクトリを定期的に監視して時間がきたメールを送信して削除

実際は、メールの受信と保存が1ステップ、送信(削除)とあわせて2段階の処理になります。

前提

独自ドメインをとってメールサーバを管理している環境なら「すぐできます」ということです。(Postfix じゃなくて qmailsendmail でも同様のことはできると思いますが、うちの環境が Postfix なので)

第1ステップ:メール受信

まず、エイリアス(/etc/aliases)に受け付けるメールアドレスを列挙します。エイリアスを変更したら「newaliases」でDB更新を忘れずに。

$ e /etc/aliases
0:  "| 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 を作ることにします。やりたいことは次の通り。

  1. 標準入力からメールの内容を読み取り
  2. ヘッダの内容を書き換えて
  3. 送信時間を付記したファイルへ保存する

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


Creative Commons License