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
endMail Fetcher の挙動メモ
- アカウント追加時に CAPA コマンドで UIDL コマンドが使えるかを見ている
- TCPセッションごとのメッセージ番号が 1 から順番の数値になっていないダメ
- 200件ごとにわけてインポートしようとする。200件ごとに STAT / LIST / RETR / DELE が走る
- (Gmail 的に) 同じ Message-ID のメールは重複しないので、何度実行しても大丈夫
- TRANSACTION フェーズで QUIT を発行したあと、こちらの応答を待たずに接続を切るっぽい
- うまくメッセージの削除が行われないうちに次のセッションがはじまるので困る → 接続を1ずつにすることで解決
環境変数で設定を変える的なモジュール Config::ENV
なんかもっと簡単なのがあればいいなー と思ったのでつくりました。
package MyConfig;
use Config::ENV 'FOO_ENV';
common +{
name => 'foobar',
};
config development => +{
dsn_user => 'dbi:mysql:dbname=user;host=localhost',
};
config test => +{
dsn_user => 'dbi:mysql:dbname=user;host=localhost',
};
config production => +{
dsn_user => 'dbi:mysql:dbname=user;host=127.0.0.254',
};
config production_bot => +{
parent('production'),
bot => 1,
};
#
use MyConfig;
MyConfig->param('dsn_user');みたいに書くと、$ENV{FOO_ENV} の値によって別の値をつかうようにするってだけです。
設定ファイルを別にわけたいみたいなのは適当に do したらいいだけだし、切替える機能だけほしいのです。
gerry++
gerry++
AnyEvent::setTimeout
AE::timer のインターフェイスが覚えられないなーと思うので、JS の setTimeout に似たものを書きました。
use AnyEvent::setTimeout;
setTimeout(sub {
warn "1sec!";
}, 1000);とか書けます。ちょっと細工をしていて、ファイルの最後に AnyEvent->condvar->recv とかおまじないを書かなくても、終了時に全てのタイマーの終了を待ちます (node.js 風にしたいけどできないのか的なことを言ってたら id:motemen:detail 先生ができそうなことを言ってくれたのでやってみた感じです)
ただ、このモジュールの管理下にあるタイマーしか終了時に待てないのがいまいち感あります……
とりあえず setTimeout さえあれば JSDeferred をそのまんま移植できるなーと思った感じなんですが、それやって意味があるのか謎なのでやめました。
まぁこういうの書いてるうちに AE::timer のインターフェイスとか覚えちゃうんですけど……
HTML メール
どうやって作るのが簡単なんでしょうか… 以下のようにしがちですがよくわかりません
use Encode;
use Email::MIME;
use Email::MIME::CreateHTML;
my $html = <<EOS;
<!DOCTYPE html>
<head>
<title>タイトル</title>
</head>
<body>
<h1>ああああ</h1>
</body>
</html>
EOS
my $text = <<EOS;
テスト
EOS
my $email = Email::MIME->create_html(
header => [
From => 'from@example.com',
To => 'cho45@lowreal.net',
Subject => encode("MIME-Header-ISO_2022_JP", 'タイトル'),
],
body => encode('iso-2022-jp', $html),
body_attributes => {
content_type => 'text/html',
charset => 'iso-2022-jp',
encoding => 'base64',
},
text_body => encode('iso-2022-jp', $text),
text_body_attributes => {
content_type => 'text/plain',
charset => 'iso-2022-jp',
},
);
print $email->as_string;
#use Email::Send;
#my $sender = Email::Send->new({mailer => 'SMTP'});
#$sender->send($email);