今から1年ほど前、「個人開発3大やめとけ」というネタが、エンジニア界隈で軽くバズりました。
3つのうち2つは個人開発者の心構えの話ですが、最後の1つだけ妙に具体的で、あるサービスが名指しされています(笑)
その名指しされていた某サービスを利用している方から、「メールサーバーを移管したい」というお話をいただきました。会社のメールなのでアカウントは複数。過去メールは全部持っていきたい&フォルダ分けも既読も崩したくない…という、いたって普通のご要望です。
ところが、この「普通の要望」を叶える手段が意外とありません。
- 移行先が Google Workspace なら、公式にかなり賢いデータ移行サービスがあるが、今回の移行先では使えない。
- Thunderbird や Becky! に新旧両方のアカウントを設定してドラッグ&ドロップ…は、ただの罰ゲームで禿げる。
- 無料で使えるツールはありそうでない。定番の imapsync は公式バイナリが有料(執筆時点で €72〜)。オンライン版は 3GB まで無料だが、顧客のメールパスワードを第三者の Web サービスに入力するのは論外。
- 公式の Docker イメージなら、無料で動かせるようです。
ならば、せっかくの機会だし作ればいいじゃない!…というわけで、Python で書きました。
この記事はそのコードを軽く紹介しつつ、実装中に思い知った「IMAP のルール、カオスすぎでは?」という話です。
作ったもの
IMAP サーバー間で、メール・フォルダ・フラグを一方向ミラーリングする CLI ツールです。
imap_sync/
├── imap_sync.py # CLI(設定読込・アカウントループ・ログ)
├── imap_connector.py # IMAP接続・操作の低レベルAPI
├── sync_engine.py # 同期ロジック(フォルダ・メール・フラグ)
└── config.yaml # 接続情報・同期設定
依存は imapclient と pyyaml だけ。本体3ファイルで1,300行ちょっとです。
ポイントは次の3つ。
- 何度実行しても差分だけが同期される(増分同期)ので、移行直前まで何回でも回しておける。
- 既読・スター等のフラグと受信日時(INTERNALDATE)を保持する。「移行したら全部未読になった」を起こさない。
- デフォルトがドライラン。
--no-dry-runを明示しない限り、何があっても書き込まない。
設定はこんな感じの YAML です。
accounts:
- name: "info"
source:
host: "mail.old-server.example.com"
port: 993
ssl: true
user: "info@example.com"
password: "********"
target:
host: "sv0000.new-server.example.jp"
port: 993
ssl: true
user: "info@example.com"
password: "********"
sync:
dry_run: true # true=変更なしでログのみ出力
delete_target_extra: true # ソースにないメール/フォルダをターゲットから削除
exclude_folders: [] # 同期除外フォルダ
max_workers: 4 # アカウント並行処理数
実行はこう。
# まずドライラン(何が起きるかログに出すだけ)
python imap_sync.py --dry-run
# 差分に問題がなければ本番
python imap_sync.py --no-dry-run
ここまでは、ごく普通の CLI ツールです。問題はここから。
本題:IMAP のルールがカオスすぎる
IMAP は前身の RFC 1730 が1994年、現行の RFC 3501 でも2003年という、四半世紀選手の枯れたプロトコルです。枯れている = 素直…だと思いたいじゃないですか。。。
その1:フォルダ名は「サーバーに聞け」
フォルダ階層の区切り文字(デリミタ)は、RFC 上「LIST コマンドの応答でサーバーが教えてくれる」ことになっています。つまり 区切り文字が実装依存なのが仕様 です。Work/2025 の世界と Work.2025 の世界がある。
さらに Courier-IMAP 系のサーバーには「すべてのフォルダは INBOX の子であるべし」という宗教があり、Sent は INBOX.Sent という名前になります。
知らずに移行先(Courier 系)へフラットな名前でフォルダを作りに行った結果がこちら。
[ERROR] フォルダ作成失敗: Sent - create failed: Invalid mailbox name.
[ERROR] フォルダ作成失敗: Trash - create failed: Invalid mailbox name.
Sent が「不正なメールボックス名」。初見だと意味が分からないです。対処として、フォルダ名の対応表を設定で書けるようにしましたが、イケてない…。
folder_mapping:
"Drafts": "INBOX.Drafts"
"Sent": "INBOX.Sent"
"Junk": "INBOX.Junk"
"Trash": "INBOX.Trash"
フォルダ名の文字コードも特殊
フォルダ名の文字コードも特殊で、IMAP は modified UTF-7(RFC 3501 5.1.3)という方言でフォルダ名をやり取りします。
UTF-7 のシフト文字 + を & に、Base64 の / を , に差し替えたもので、日本語はこうなります。
請求書 → &istsQmb4-
営業部 → &VbZpbZDo-
もし知らなければ、こんなのログで見かけたら、まず文字化けを疑います(笑)
ただ今回の場合は、imapclient で folder_encode を有効にしておけば(デフォで有効)、ここは良しなに変換してくれます。
調べたところ、2021年の IMAP4rev2(RFC 9051)からは、日本語フォルダ名をそのまま UTF-8 で書けるようになったのだそうで…。
その2:INBOX だけ VIP 待遇
RFC 3501 いわく、INBOX は大文字小文字を区別しない特別な名前です。他のフォルダは実装依存で区別されるのに、INBOX だけは Inbox でも inbox でも同じ扱い。実際、Inbox で返してくるサーバーが存在します。
# INBOX は大文字に正規化(サーバーにより Inbox/inbox 等で返る場合がある)
if name.upper() == 'INBOX':
name = 'INBOX'
INBOX は削除もできませんので、ミラーリングの「余分なフォルダは消す」処理から常に除外する必要があります。VIP なので。
その3:読んだら既読になる
FETCH で本文(BODY[])を取得すると、そのメールに \Seen フラグが立ちます。人間がメーラーで読むなら自然な仕様ですが、同期ツールがこれをやると、全メールを既読に塗り替えながら移行することになります。大事故です。
回避には PEEK を付けます。
# BODY.PEEK[] を使用して \Seen フラグが立つ副作用を防止
results = self.client.fetch([uid], ['BODY.PEEK[]', 'FLAGS', 'INTERNALDATE'])
「読む」と「覗き見る」が別コマンドという、覗きに市民権のある珍しい世界?です。
その4:\Recent は触るな危険
\Recent は「このメール、前回誰かが見に来た後に届いた新入りですよ」をサーバーが表すフラグで、クライアントからは設定も削除もできません。読み取り専用。
つまり同期のしようがない。コピー時・フラグ比較時・フラグ設定時と、あらゆる経路で除外し続ける羽目になります。
# \Recent フラグは除外(サーバー管理のため手動設定不可)
flags.discard(b'\\Recent')
なおこのフラグ、2021年の IMAP4rev2(RFC 9051)でついに廃止されたそうです。四半世紀かけて、やっと「これは要らなかった」という結論が出たわけですね…。
その5:メールに「使える ID」が無い
2つのサーバー間で、「このメールとこのメールは同じもの」と照合するキーが要ります。ところが、これが無い。
- UID: フォルダ内でだけ一意。サーバーが違えば別体系なので照合には使えない。おまけに UIDVALIDITY という値が変わった瞬間、全 UID がご破算になる。
- Message-ID ヘッダ: 照合に使える唯一の現実解。ただし必須ヘッダではないので、持っていないメールが合法的に存在する。
- しかも一意である保証がない(致命的)。
「一意である保証がない」は理屈の話ではなく、実際に同一フォルダ内で同じ Message-ID を持つメールが2通ずつ、複数組見つかりました。
Outlook から送られたものと、Amazon SES 経由の通知メールです。天下の Microsoft と Amazon が発行した ID が被る世界で、メールを照合しなければなりません…。
結局、Message-ID を持たないメール向けに Date/From/To/Subject の SHA-256 ハッシュでフォールバック ID を作りました。
def _computeFallbackId(self, headerBytes, uid):
try:
msg = email.message_from_bytes(headerBytes, policy=email.policy.default)
# Date/From/To/Subject の値を連結してハッシュ元にする
parts = []
for key in ('Date', 'From', 'To', 'Subject'):
value = msg.get(key, '')
if value:
# 折り返し由来の改行・連続空白を畳み、サーバー間の差を吸収
normalized = re.sub(r'\s+', ' ', str(value)).strip()
parts.append(f'{key}:{normalized}')
if parts:
hashSource = '\n'.join(parts)
digest = hashlib.sha256(hashSource.encode('utf-8', errors='replace')).hexdigest()[:32]
return f'__no_msgid__hash_{digest}'
except Exception as e:
self.logger.warning(
"ヘッダハッシュ計算に失敗 (UID=%s): %s: %s", uid, type(e).__name__, e
)
# ヘッダが空またはパース失敗時はUIDベース(最終手段)
self.logger.warning("ハッシュ計算不可 (UID=%s): UIDベースのフォールバックID使用", uid)
return f'__no_msgid__uid_{uid}'
同一内容ならソースとターゲットで同じ ID になるので、照合に参加できるという理屈です。
その6:「削除」は削除ではない
IMAP に「メールを削除するコマンド」はありません。手順は2段階です。
-
\Deletedフラグを立てる(この時点ではまだ消えない) - EXPUNGE コマンドで物理削除する
問題は EXPUNGE の挙動で、こいつは フォルダ内の \Deleted が付いた全メールを消します。自分が付けた分だけではなく、他のクライアントが「あとで消そう」と付けておいた分も巻き添えにする。
「指定した UID だけ EXPUNGE したい」には UIDPLUS という拡張が必要で、対応しているかどうかはサーバー次第。CAPABILITY コマンドで聞いてから分岐します。
if b'UIDPLUS' in self.client.capabilities():
self.client.uid_expunge(batch) # 指定UIDのみ削除
else:
self.client.expunge() # \Deleted の付いた全メールが対象(巻き添え上等)
その7:消せないフォルダ、勝手に生えるフォルダ
フォルダの削除も一筋縄ではいきません。
まず、中身のあるフォルダの削除を拒否するサーバーがある。SELECT 中(開いている状態)のフォルダの削除を拒否するサーバーもある。なので「全メール削除 → いったん INBOX に避難 → フォルダ削除」という三段の様式美が必要になります。
それをやってもなお、こう返されます。
[ERROR] フォルダ削除失敗: INBOX.Trash - delete failed: Cannot delete this folder.
例えば、Trash や Junk はシステムフォルダなので削除不可な上に INBOX.alias や INBOX.mailfilter というシステムフォルダが、こちらの意思とは無関係に勝手に生えてきます。
「ソースに無いものはターゲットから消す」のがミラーリングなので、放っておくと同期のたびに削除を試みては失敗し続けることになります。不死身のフォルダと戦っても仕方がないので、exclude_folders 送りにして手打ちとしました。うーん…イケてない…。
おまけ:仕様の外にも罠
プロトコルのカオスを抜けた先には、実装のカオスが待っています。
- ライブラリ(imapclient)のバージョンやサーバーの組み合わせで、フラグや FETCH 応答のキーが bytes だったり str だったり、大文字だったり小文字だったりする。最終的に「応答のキーを部分一致で探す」という敗北感あふれる関数が生まれました…。
- 移行元の共有サーバーの TLS 証明書がサーバーのホスト名でしか有効でなく、顧客の独自ドメイン名で繋ぐと検証エラー。証明書検証を任意で切れるオプション(
verify_cert: false)を後付けする羽目に…。
どちらも IMAP は悪くないと言えば悪くないのですが、髪へのダメージはなかなかです。
まとめ
さんざん文句を書きましたが、この方言だらけの仕様は「30年前のメール資産を壊さずに拡張し続けてきた」結果でもあります。
\Recent が廃止されたように、ゆっくりとは前進しているわけです(よね?)。メールが今日も普通に届くのは、このカオスを飲み込み続けた実装者たちのおかげです。
オチ
メールサーバーの移管は、同期ツールを回して終わりではありません。最後に某サービスの管理画面へログインし、DNS やらメール設定やらをポチポチする作業が待っています。
そしてこの管理画面、UI/UX が酷すぎると専らの評判です。
まず、目的の設定にたどり着けない。たどり着いたと思ったら、「これがダークパターンのお手本だ!」と言わんばかりに別サービスへの勧誘が挟まる。どのボタンが「進む」でどれが「申し込む」なのか、毎回一呼吸置いて確認する必要がある…。
噂には聞いていたものの、いざ実際に使ってみると想像以上に酷すぎましたので、右上の方になぜかある謎の検索ボックスへ「たぶんこんなメニュー名かな?」と予想してメニュー名を入れ、検索結果からアクセスしてました。
UI/UXの酷さを検索という力技で解決するのは、なかなか画期的です(笑)
IMAP の方言と管理画面の迷宮、このダブルパンチで髪の毛が1000本ほど抜けて、禿げかけました。人間の髪はざっくり10万本だそうなので、まだ1%ですが。
10万と言えば、この某サービスを運営する GMO インターネットグループの代表・熊谷氏は、Claude Code を使って2カ月で10万行のコードを書いたそうです。以前、この話を検証する記事を書きました。
62歳の代表が自ら手を動かして10万行。正直、かっこいいと思っています。そして Claude には、デザインやプロトタイプを丸ごと作ってくれる Claude Design というツールもあります。
というわけで熊谷さん、次の2カ月で、あの管理画面の UI/UX を Claude Design で再設計してもらえませんか?残り9万9000本の髪より、切にお願い申し上げます。