2011年 07月 27日

Maildir のメールを Gmail にそのままインポートしたい

手元に昔使っていた Maildir 形式で保存されているメールがたくさんあるのを、Gmail にインポートしたいと思った。

残念ながら、いくら手元に完全な MIME のメッセージがあっても、Gmail にはそのままアップロードするみたいな機能がないために、簡単にそのままインポートすることができない。とはいえ Gmail には Mail Fetcher 機能 (POP3 で外部サービスのメールをとりにいく) というのがあるので、それを利用することにした。(メールで送ってしまう、という手もあるけど、From/To が書きかわったり、時刻が狂ったりして嫌だった)

いろいろ試した (Perl で Net::Server::POP3 を使ったり) した結果、結局必要な実装だけ自力で書いた。なんとなく TCP のサーバ実装を書くみたいなときは Ruby 使うのが手に馴染むので Ruby で書いた。割と簡単だったのでさっさと自力で書けばよかったと思った……

  • 以下のスクリプトの冒頭をいい感じに設定
  • パブリックなサーバでポート1110をあけて、起動する
    • telnet [server] 1110 とかで接続できることを確認
  • [設定] → [アカウントとインポート] → [POP3 を使用したメッセージの確認] から [POP3 のメールアカウントを追加] を選ぶ
  • [メールアドレス] にインポートしたメールのメールアドレスを入れる (間違ってると正しくインポートされない)
  • [ユーザ名] [パスワード] にスクリプト冒頭に設定したものを入れる
  • [POP サーバー] に立ち上げたサーバのホスト名を入れる
  • [ポート] は 1110 にする
  • [アカウントを追加] ボタンをおすとたぶんうまくいく
#!/usr/bin/env ruby

USER = 'cho45'
PASS = 'xxxxxx'
MESSAGES = Dir.glob('./path/to/Maildir/cur/*')

require 'socket'
class POP3Session
	def self.messages=(messages)
		@@messages = messages
	end

	def initialize(s)
		@s = s
		authorization
	end

	def authorization
		p :authrization
		post "+OK POP3 server ready"
		session do |command, args|
			case command
			when 'USER'
				@user = args[0]
				if @user == USER
					post "+OK name is a valid mailbox"
				else
					post "-ERR never heard of mailbox name"
				end
			when 'PASS'
				pass = args[0]
				if pass == PASS
					post "+OK maildrop locked and ready"
					transaction
				else
					post "-ERR invalid password"
				end
			when 'QUIT'
				@s.close
			else
				post "-ERR unknown command"
			end
		end
	end

	def transaction
		p :transaction
		session do |command, args|
			p [command, args]
			case command
			when 'CAPA'
				post "+OK capability list follows."
				post "USER"
				post "UIDL"
				post "."
			when 'STAT'
				nom  = @@messages.size # number of messages
				size = @@messages.inject(0) {|r,i| r + i.size }  # size of the maildrop in octets
				post "+OK #{nom} #{size}"
			when 'LIST'
				if args.size.nonzero?
					msg = args[0].to_i - 1
					size = @@messages[msg].size
					post "+OK #{msg} #{size}"
				else
					post "+OK scan listing follows"
					@@messages.each_with_index do |m,i|
						post "#{i+1} #{m.size}" unless m.deleted
					end
					post "."
				end
			when 'UIDL'
				if args.size.nonzero?
					msg = args[0].to_i - 1
					post "+OK #{msg} #{@@messages[msg].hash}"
				else
					post "+OK message-id listing follows"
					@@messages.each_with_index do |m,i|
						post "#{i+1} #{m.hash}" unless m.deleted
					end
					post "."
				end
			when 'RETR'
				msg = args[0].to_i - 1

				m = @@messages[msg]
				if m
					post "+OK message follows"
					post m.content
					post "."
				else
					post "-ERR no such message"
				end
			when 'DELE'
				msg = args[0].to_i - 1
				m = @@messages[msg]
				if m
					m.deleted = true
					post "+OK message deleted"
				else
					post "-ERR no such message"
				end
			when 'NOOP'
				post "+OK"
			when 'RSET'
				@@messages.each do |m|
					m.deleted = false
				end
				post "+OK"
			when 'QUIT'
				update
			end
		end
	end

	def update
		p :update
		post "+OK"
		@@messages.reject! {|i| i.deleted }
		@s.close
	end

	private
	def post(msg="")
		puts msg
		@s.write("#{msg}\r\n")
	end

	def session(&block)
		while line = @s.gets
			line.chomp!
			puts line
			command, *args = *line.split(/\s+/)
			res = yield command, args
		end
	end

	class Message
		attr_accessor :deleted

		def initialize(file)
			@file = file
		end

		def content
			File.read(@file)
		end

		def size
			File.size(@file)
		end
	end
end

server = TCPServer.open(nil, 1110)
puts "User: #{USER}"
puts "Pass: #{PASS}"
puts "POP3: %3$s Port: %2$s" % server.addr

POP3Session.messages = MESSAGES.map {|f|
	POP3Session::Message.new(f)
}

loop do
	begin
		POP3Session.new(server.accept) # コネクションを1つずつしか受けつけないように
	rescue => e
		p e
	end
end

Mail Fetcher の挙動メモ

  • アカウント追加時に CAPA コマンドで UIDL コマンドが使えるかを見ている
  • TCPセッションごとのメッセージ番号が 1 から順番の数値になっていないダメ
  • 200件ごとにわけてインポートしようとする。200件ごとに STAT / LIST / RETR / DELE が走る
  • (Gmail 的に) 同じ Message-ID のメールは重複しないので、何度実行しても大丈夫
  • TRANSACTION フェーズで QUIT を発行したあと、こちらの応答を待たずに接続を切るっぽい
    • うまくメッセージの削除が行われないうちに次のセッションがはじまるので困る → 接続を1ずつにすることで解決