サーバにメッセージを保存したままGmailからIMAPでメッセージを取得する

経緯

Raspberry PIでサーバを立てたので、こいつにメールサーバを立ち上げてGmailのバックアップにしようとたくらんだ。 普段のメールはGmailからPCやスマホのアプリで見るので、Gmail上にメッセージを残し既読フラグも一切更新せずにメッセージをダウンロードする方法が欲しかった。 欲しいツールがなかったので、Rubyでちゃちゃっとツールを作った。

GmailからIMAPでメッセージをダウンロードするためには

まず最初にアプリパスワードの発行が必要。

  1. Googleアカウントにアクセス
  2. ログインとセキュリティに行く
  3. 2段階認証プロセスを有効化する
  4. アプリパスワードを発行(ここで発行したパスワードをIMAPクライアントに設定する)

fetchmailで試行錯誤したがダメだった

fetchmailで試行錯誤したのだが、次の問題が解決できなかった。

  • メッセージをダウンロードするときに既読(Seen)フラグが付くのを止めることができないので、fetchmailでダウンロードすると他のメールクライアントから既読になってしまう。今回はバックアップ目的でメッセージを取得するので、勝手に既読が付くと困る。
  • サーバにメッセージを残したままダウンロードすると、毎回、全メッセージをダウンロードしてしまう。

しょうがないので、自分でツールを作ることにした。

ツール(imapfetch)の作成

ツールの名前はimapfetchにした。Githubで公開済み。

github.com

とにかくすぐに動くツールが欲しかったので、gem化やユニットテストの手間暇も惜しみ、All in Oneのスクリプトでちょっとずつ書き足して動かしデバッグしながら作成、半日くらいでできた。

作戦は次のとおり。

  • Rubyの各種ライブラリ(imap、logger、yaml、pstore)をうまく活用して、自分がやりたいことの実装に集中した。
  • コマンドラインオプションは面倒なので実装しない。cronで定期実行したいので、設定は全部YAMLの設定ファイルに書くことにした。
  • 前回ダウンロードしたメッセージを覚えておくためにUIDを保存することにした。IMAPではメッセージごとにUIDという固有のID番号がつく。UIDはメールボックス毎に一意かつ必ず増加するので、前回ダウンロードした最後のUIDから増えていた分をダウンロードする。ツールがUIDを覚えておくことで、サーバ上のメッセージの既読(Seen)等のフラグを更新することなく差分だけをダウンロードできる。

imapfetch実装の簡単な解説

注意: わかりやすくするため、コードは余分な処理を削って引用している。

IMAPサーバへ接続する。

imap = Net::IMAP.new(c['server'], port: c['port'], ssl: c['ssl'])

ログインする。Gmailの場合、パスワードはアプリパスワードを使う。

  imap.login(c['username'], c['password'])

メールボックスを選択。 読み出し専用でアクセスするためSELECT命令ではなくEXAMINE命令を使う。IMAPのメールボックス名はUTF-7で取り扱うため変換する。

  imap.examine(Net::IMAP.encode_utf7(c['mailbox']))

前回のUIDの取り出し。 YAML書式のPStoreにサーバ/ユーザ/メールボックス/UIDと階層化して保存しておく。

  store = YAML::Store.new(File.join(ENV['HOME'], '.imapfetchids'))
  last_uid = store.transaction{
    store[host] = {} unless (store.root? host)
    store[host][c['username']] = {} unless (store[host].key? c['username'])
    store[host][c['username']][c['mailbox']] = {} unless (store[host][c['username']].key? c['mailbox'])
    store[host][c['username']][c['mailbox']]['last_uid'] || 0
  }

サーバの最後尾のUIDの確認。 IMAPでは*で最後尾のメッセージを取得できる。

  max_uid_msg = imap.fetch('*', 'UID').last

UIDの一覧を取得。 ついでにメッセージのサイズも取得。

    msg_list = imap.uid_fetch(min_uid..max_uid, %w[ UID RFC822.SIZE ])

取得したリストでループをまわす。 メッセージをダウンロードする毎に最新のUIDを保存するので、pstoreのトランザクションを発行する。

    for target in msg_list
      target_uid = target.attr['UID']
      target_octets = target.attr['RFC822.SIZE']
      store.transaction{
        ...省略...
      }
    end

メッセージをダウンロードする。 UID指定なので必ず1メッセージのダウンロードになるが、配列で結果が返るので一応ループをまわしておく。

        for message in imap.uid_fetch(target_uid, 'RFC822')
          ...省略...
        end

メッセージをMDA*1に渡す。

          IO.popen(c['mda'], 'w') {|output|
            output << message.attr['RFC822']
          }

MDAの実行結果を確認。 成功したらpstoreにUIDを保存する。 失敗したらabortで即終了、続行すると次回取得時に失敗したUIDを取りこぼすので、続行できない。

          if ($?.exitstatus == 0) then
            store[host][c['username']][c['mailbox']]['last_uid'] = target_uid
          else
            fetch_log(Logger::FATAL, "MDA failed: #{c['mda']} (exit #{$?.exitstatus})")
            abort('MDA failed')
          end

以上で終了。

*1:Mail Delivery Agent