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
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() って関数を拡張していることを補足し忘れてました。もし使う場合は意味を適当に汲み取って実装してください。