ActionMailerによるメール受信処理を高速化?スケール?させる

ActionMailer をよりによって

% script/runner 'Receiver::receive(STDIN.read)'

などとして使っているとその重さに途方にくれてしまう。

というわけで、メール処理デーモンを使うことを考える。初めはActiveMessaging ActiveMQの組み合わせでゴニョゴニョしようと思ったけど、ActiveMQ のフットプリントが思ったより大きくて、チューニングするのも面倒で手に余ったので、結局 MTA からメールを受け取ったあと、オレオレ HTTP 拡張で RFC2822 形式のメールを HTTP リクエストに変換するスクリプトを書いて、ActionController に処理させることにした。HTTP にしちゃえば、あとはお手軽 DNS ラウンドロビンも mod_proxy_balancer も使える。運用を考えると、メッセージングのプロトコルは極力 HTTP に落ち着けるべきだと思うなあ。

というわけでものすごくいい加減なスクリプトを。

#!/usr/bin/env ruby
require 'optparse'
require 'net/http'
require 'uri'

class DomainError < RuntimeError; end

def proc_mail(input)
  headers = {}
  body = ''
  state = 0
  img = nil

  until input.eof?
    line = input.readline.rstrip
    folded = /^[ \t]/.match(line)
    # よくあるステートマシン
    case state
      when 0
        raise DomainError, "Malformed mail header" if folded
        envelope_prologue = /^From\s+([^\s]+)\s+(.+)/.match(line)
        if envelope_prologue
          # mbox 形式の 1 行めを一応取っておく
          headers['X-Envelope-Prologue-Sender'] = [envelope_prologue[1]]
          headers['X-Envelope-Prologue-Timestamp'] = [envelope_prologue[2]]
        else
          img = line
          state = 1
        end
      when 1
        if folded
          img << ' ' << line[folded[0].length .. -1]
        else
          m = /^([^:]*):[ \t]*(.*)/.match(img) or raise "Malformed mail header"
          key = m[1]
          val = m[2]
          if headers.has_key?(key)
            headers[key] << val
          else
            headers[key] = [val]
          end

          img = line
        end
    end
    break if line == ''
  end

  [ headers, input.read ]
end

file = nil
pass_env = false
opts = OptionParser.new do |o|
  o.on(
    '-f r2822', '--file r2822', String,
    "specify a file containing a RFC2822 text") { |v| file = v }
  o.on(
    '-e', '--env', TrueClass,
    "pass environment variables as query strings") { |v| pass_env = v }
end
begin
  uri_str = opts.parse(*ARGV)[0]
  raise ArgumentError if uri_str.nil?
rescue
  STDERR << opts.to_s
  exit 255
end

begin
  input = file.nil? ? STDIN: open(file, 'r') \
    rescue (raise DomainError, "cannot open file #{file}")
  uri = URI.parse(uri_str) \
    rescue(raise DomainError, "invalid URI: #{uri_str}")
  raise DomainError, "scheme `%s' is not supported" % (uri.scheme || '(empty)')\
    unless %w(http https).include?(uri.scheme)

  mail_headers, mail_body = proc_mail(input)
  if pass_env
    query_img = ''
    ENV.each do |key, val|
      query_img << URI.encode(key) + '=' + URI.encode(val) << '&'
    end
    uri.query = query_img 
  end
  req = Net::HTTP::Post.new(uri.request_uri)
  mail_headers.each do |key, vals|
    vals.each do |val|
      # RFC2822 のヘッダを X-RFC2822-XXX という HTTP ヘッダに変換する
      req.add_field "X-RFC2822-#{key}", val
    end
  end
  req.body = mail_body
  req.content_type = 'text/x-rfc2822'
  req.basic_auth uri.user, uri.password if uri.user
  res = Net::HTTP.new(uri.host, uri.port).request(req)
  STDOUT << res.body
  exit 0 if res.kind_of?(Net::HTTPSuccess)
  exit 2 if res.kind_of?(Net::HTTPClientError)
  exit 3 if res.kind_of?(Net::HTTPServerError)
  exit 1
rescue DomainError => e
  STDERR << "#{File.basename($0)}: #{e.message}\n"
  exit 1
rescue
  raise
end

ActionController側は

class ReceiverController < ActionController::Base
  @@consider_all_requests_local = false

  before_filter :receiver_filter

  def index
    if request.respond_to? :mail_body
      # ここに受信処理を書く
      # Receiver.receive request.mail_body
      render_text "Accepted", 204
    else
      render_text "Not Implemented", 501
    end
  end

protected
  # この辺適当すぎ
  def rescue_action(e)
    logger.error "#{e.class.to_s} #{e.message.gsub(/\n/, ' ')}\n" + e.backtrace.join("\n")
    render_text "#{e.class.to_s}\n#{e.backtrace[0]}\n#{e.message.to_s}", 500
  end

  def receiver_filter()
    # request.remote_ip はリバースプロキシの場合も面倒みてくれる
    unless %w(127.0.0.1).include?(request.remote_ip)
      response.content_type = 'text/plain'
      response.body = 'Request not allowed'
      response.headers['Status'] = '403 Access Denined'
      return false
    end

    if request.content_type == 'text/x-rfc2822'
      img = ''
      request.env.each do |key, val|
        if m = /HTTP_X_RFC2822_(.*)/.match(key)
          hdr_name = m[1].gsub(/_/, '-').gsub(/[a-zA-Z0-9]+/) { |p| p.titlecase }
          img << hdr_name + ': ' + val << "\n"
        end
      end

      img << "\n" << request.raw_post
      img.freeze

      class << request
        attr_accessor :mail_body
      end
      request.mail_body = img
    end
    true
  end
end

最初のスクリプトを mproxy.rb などとして保存して (場所は好みで) .forward には

|"ruby script/mproxy.rb http://localhost:3000/receiver/"

なんて書いておけばいいわけで。

..何かセキュリティーホールある予感。

追記: String#titlecase() って関数を拡張していることを補足し忘れてました。もし使う場合は意味を適当に汲み取って実装してください。