Outlook OAuthの長い旅: IMAPは壊れた、Graphよ永遠なれ
これは、たった1つのGitHubのissue —「Microsoftアカウントでログインできない」— が、自前のAzure ADアプリケーションの登録、RustによるMicrosoft Graph REST APIのトランスポート層のまるごとの構築、そしてメールがようやく読み込まれるまでに連鎖した7つの不具合のデバッグへと膨らんだ話です。設定の修正で済むはずだったものが、MailVaultがMicrosoftと話す方法をゼロから作り直すことになりました。
不具合の報告
2人の利用者が、別々の角度から同じことを報告しました。企業のM365の利用者は、サインインしようとするとAADSTS900971: No reply address providedを見ました。個人のOutlook.comの利用者はログインの画面は通れたものの、アプリがIMAPで接続しようとするとAUTHENTICATE failedにぶつかりました。症状は同じ — 「ログインできない」 — ですが、根本の原因はまったく違う2つでした。
問題1: 借り物のクライアントID
MailVaultの当初のMicrosoft OAuth2のフローは、Thunderbirdのよく知られた公開のクライアントIDを使っていました。これはよくあるやり方です — Thunderbirdは、ほかのメールクライアントが相乗りできるように、MicrosoftとGoogleにOAuth2のアプリを登録しています。うまくいかなくなるまでは、とてもうまくいきます。
企業のM365のテナントでは、Thunderbirdのアプリに私たちのlocalhost:19876/callbackのリダイレクトURIが登録されていません。Azure ADは完全一致を求めますし、ThunderbirdのアプリはThunderbird自身のリダイレクトURIしか並べていません。修正は、MailVault自身のAzure ADのアプリを、私たちの正確なリダイレクトURIとともに、パブリッククライアント(シークレットなし、PKCEのみ)として、組織のアカウントと個人のMicrosoftのアカウントの両方に対応する形で登録することでした。
問題2: MicrosoftがIMAPを壊した
個人のOutlook.comの問題のほうが厄介でした。2024年12月以降、Microsoftには個人のアカウントのIMAP OAuth2に影響するサーバー側の後退があります。OAuth2のフローが成功したあと、XOAUTH2 SASLのやり取りが「User is authenticated but not connected.」で失敗します。これはクライアントの不具合ではありません — MicrosoftのIMAPのサーバーが、正当なトークンを拒んでいるのです。Microsoftのサポートのフォーラムのスレッドは何か月も開いたままで、修正の見込みは立っていません。
個人アカウントではアプリパスワードなら今も動きますが、それではOAuth2の意味がありません。まったく別の方法が必要でした。
最終手段: Microsoft Graph API
IMAPが壊れているなら、IMAPを使わなければよい。Microsoft GraphはRESTのAPIで、IMAPをまったく介さずにメールボックスへ直接アクセスできます。outlook.office365.com:993につないでIMAPのプロトコルを話す代わりに、Bearerのトークンを付けてgraph.microsoft.com/v1.0/me/messagesへHTTPSの要求を送ります。同じOAuth2のトークンで、通信の手段はまったく別です。
Graphのトランスポートを作るというのは、アプリのほぼすべての層に手を入れるということでした。
Rustのバックエンド — list_folders()、list_messages()、get_message()、delete_message()、set_read_status()、get_mime_content()を実装したGraphClientの構造体を持つ、新しいgraph.rsのモジュールです。GraphのJSONの応答のためのデータ構造(GraphMessage、GraphMailFolderなど)と、Graphのメッセージを既存のEmailHeaderの構造体へ変換するメソッドも用意しました。これらすべてをフロントエンドへ公開する、6つの新しいTauriのコマンドもあります。
OAuth2のスコープの分岐 — IMAPのアカウントにはIMAP.AccessAsUser.AllとSMTP.Sendのスコープが要ります。GraphのアカウントにはMail.ReadWriteとMail.Sendが要ります。スコープの選択は、利用者が「Sign in with Microsoft」を押したその時点で、メールのドメインが既知の個人向けMicrosoftのドメイン(outlook.com、hotmail.com、live.com、および12の地域ごとの派生)かどうかによって行われます。
フロントエンドのストア — Zustandのストアにあるすべてのメールの操作 — ヘッダーの読み込み、メールの選択、既読と未読の切り替え、削除、検索、先読み — にif (isGraphAccount(account))の分岐が必要でした。Graphのメッセージは整数のUIDではなく不透明な文字列のIDを使うので、Graphのメッセージに合成した連番のUIDを割り当て、APIを呼ぶときに元へ訳し戻す対応付けの層(_graphIdMap)を保っています。
背後のパイプライン — 背後でのヘッダーの読み込みと内容のキャッシュを担うAccountPipelineのクラスは、どちらの段階にもGraph向けの経路が必要でした。ヘッダーの読み込みは、IMAPのページ取得ではなくgraphListMessagesを使ってskipに基づくページ送りをします。内容のキャッシュは、IMAPのFETCHではなくgraphCacheMime(生の.emlを取得し、Maildirへ保存し、解析する1つのRustのコマンド)でMIME全体をダウンロードします。
7つの失敗
実装が終わったので、デプロイして実際のOutlook.comのアカウントでテストを始めました。そこから始まったのは、1つ直すたびに次の問題が現れるデバッグのマラソンでした。
失敗1: 「Invalid client secret provided」 — CIのビルドがMAILVAULT_MS_CLIENT_SECRETを環境変数として設定していました。コードはそれを律儀にAzureへ送り、Azureは拒否しました。私たちのアプリはパブリッククライアント(シークレットは許可されない)として登録されているからです。修正: Microsoftに対してはclient_secret: Noneを直接書き込むこと — パブリックなPKCEのクライアントがシークレットを送ることは、決してあってはなりません。
失敗2: 接続のテストでの「AUTHENTICATE failed」 — シークレットの問題を直すと、OAuth2のフローは無事に完了しました。ところがアプリはすぐさまIMAP経由で接続をテストしようとしました — まさに壊れているプロトコルです。addAccountの関数には、Graphの分岐を持たないIMAPの接続のテストが直接書き込まれていました。修正: Graphのアカウントは、IMAPのセッションを開くのではなくgraphListFoldersを呼んでテストすること。
失敗3: 「Could not load email content」 — ヘッダーはGraph経由で問題なく読み込めましたが、メールをクリックして読もうとすると失敗しました。useChatBodyLoaderのフック — チャットビューでもスレッド表示でもメールの本文を段階的に読み込むもの — は、IMAPのfetchEmailLightで本文を取得する方法しか知りませんでした。Graphのアカウントでは、対応付けたGraphのメッセージIDを使ってgraphGetMessageを呼ぶ必要がありました。修正: GraphのIDを引いてGraph APIから取得する分岐を追加すること。
失敗4: メールが一度は読み込まれ、その後消えた — 初回はメールが問題なく読み込まれるのに、別の画面へ移って戻るとスレッドは空でした。Graphの分岐は、メールの本文をメモリ上のLRUキャッシュに入れるだけで、.emlのファイルをディスクに書いていませんでした。LRUキャッシュがそれらを追い出した時点で、本文は消えていたのです。修正: 高速なgraphGetMessageのJSONの応答でメールを表示したあと、背後でgraphCacheMimeを呼び、MIME全体をダウンロードして.emlをMaildirに保存すること。
失敗5: メールをクリックすると回り続けるスピナー — 失敗4への最初の修正では、graphCacheMimeを主な取得の手段にしていました(MIMEをダウンロードし、ディスクに保存し、解析して返す)。これは何かを表示する前に、添付ファイルも含めた生のメール全体をダウンロードします。添付ファイル付きの5 MBのメールなら、数秒のスピナーです。修正: すぐに表示するにはHTMLの本文だけを含む高速なJSONのgraphGetMessageを使い、ディスクへの永続化は背後のgraphCacheMimeに任せること。二段階の読み込みです。表示は即座に、永続化は後回しに。
失敗6: スレッド表示で内容を見るには折りたたみと展開が必要だった — スレッドの会話の表示にあるメールは、手動で折りたたんで開き直すまで「Could not load email content」と出ていました。これはReactの描画の競合状態でした。useChatBodyLoaderのフックは、読み込んだ本文を、空の状態から始まるuseRef(new Map())に保持していました。ThreadEmailItemのコンポーネントは描画の最中にこのマップを同期的に読みますが、そこへ値を入れるuseEffectが走るのは最初の描画のあとでした。本文が揃ったころには、コンポーネントはすでに「Could not load.」を描き終えていたのです。折りたたんで展開すると再描画が起こり、そのときには値の入ったマップが見つかりました。修正: メモリ上のキャッシュを確認して、フックの本体の中で(useEffectの中ではなく)同期的に本文のマップをあらかじめ埋めること。キャッシュにない本文の取得は非同期のuseEffectが引き続き担当しますが、キャッシュ済みのものは最初の描画から使えます。
失敗7: 背後の同期がまだIMAPを叩いていた — refreshAllAccountsの関数は、タイマーですべてのアカウントを更新します。アクティブでないアカウントについては無条件にIMAPでヘッダーを取得していたため、Graphのアカウントでは接続のエラーとログの氾濫が起きていました。ほかの経路はすべて直してあったのに、ここだけ見落としていたのです。修正: 背後の同期にgraphListFoldersとgraphListMessagesを使うisGraphAccountの分岐を追加すること。
リリースしたもの
最終的な結果です。個人のMicrosoftアカウント(outlook.com、hotmail.com、live.com、および地域別のドメイン)はサインインのときに自動で判別され、Graph APIのトランスポートへ振り分けられます。ユーザーは「Sign in with Microsoft」をクリックし、ブラウザで認証すれば、アプリは動きます。IMAPは介在せず、「User is authenticated but not connected」のエラーも出ません。
企業のM365のアカウントは、正しいリダイレクトURIに対応したMailVault自身のAzure ADアプリの登録を使います。OAuth2のポリシーが厳しい組織のために、アカウント設定の「Advanced」の項目でクライアントIDとテナントIDを上書きできます。
Graphのアカウントでは、メールの閲覧、削除、既読・未読の切り替えに対応しています。送信は引き続きSMTPを使います(OAuth2のトークンで問題なく動作します)。メールの本文は即座に表示するために高速なJSONで取得し、オフラインでの閲覧と添付ファイルのために、完全なMIMEをバックグラウンドでダウンロードします。
学んだこと
いちばん大きな学びは、メールクライアントに新しいトランスポートを追加するとき、増えるのは1本の経路ではなく、すべての経路が枝分かれするということです。ネットワークに触れるすべての関数が、どのトランスポートを使うかを知らなければなりません。すべてのバックグラウンドの処理、すべての先読み、すべての同期のタイマー、すべての接続テストが、です。1つでも漏らせば、新しいトランスポートのユーザーにだけ現れる不具合が生まれます。
2つ目の学び: 2段階の読み込みは、複雑さに見合う価値があります。何かを表示する前にMIMEのメッセージをまるごとダウンロードすると、待ち時間が許容できないものになります。JSONのAPIから高速なプレビューを表示し、完全な内容はバックグラウンドで遅れて保存すれば、ユーザーには即座に反応が返りつつ、オフラインのアーカイブも積み上がっていきます。
3つ目。ReactのuseRefとuseEffectの組み合わせは、最初の描画の時点で必要なデータにとっては罠です。コンポーネントが描画の最中にrefを読み、そのrefがエフェクトの中で埋められるなら、最初の描画は必ず古いデータを見ます。埋める処理をエフェクトではなくフックの本体で同期的に走らせれば、余計な再描画を増やさずに解決します。
「ログインできない」という不具合が、設定の修正で済むこともあります。まるごと新しいAPIのトランスポート層になることもあります。どちらになるかは選べません。