Gmail API の審査が重すぎて、React Native 上で IMAP / SMTP / MIME を自前で書いた

Gmailの制限付きスコープ審査と年次セキュリティ評価の壁に直面し、iCloudメールのIMAP対応へ転換。TCPソケット上でMIME解析器やSTARTTLSの二重受信防止を実装した記録。

#React Native#IMAP#プロトコル

ビジネスメールをチャットのような吹き出しUIで表示し、短文の返信を整ったビジネス敬語に変換して送るスマホアプリ「MelTalk(メルトーク)」を React Native(Expo)で開発していたときのことです。

メールアプリを作る以上、避けて通れないのが「どのメールサービスとどう繋ぐか」という通信基盤の設計です。当初、私は最も利用者が多い Gmail を最優先とし、Google 公式の Gmail API(REST)を使って連携する計画を立てていました。

しかし、技術調査を進めるうちに、個人開発者が Gmail API でメールを読み書きするアプリを一般公開するには、想像を絶する審査とセキュリティ監査の壁が立ちはだかっていることが判明しました。

この審査の壁を前に、私は Gmail 連携を一旦後回しにし、審査なしで誰でもすぐ使える iCloud メール(IMAP / SMTP) への対応へ舵を切りました。さらに、React Native 上で動く適切なメールライブラリが存在しなかったため、低レベルな TCP ソケットの上で IMAP・SMTP・MIME パーサーを一から TypeScript で自作することになりました。その格闘の記録です。

Google の「制限付きスコープ」と年次セキュリティ評価の壁

Gmail API を使ってユーザーのメール一覧を取得したり、本文を読んだり、返信を送ったりするには、gmail.readonly や gmail.send といった OAuth スコープを Google Cloud で要求する必要があります。

しかし、Google の OAuth 同意画面ポリシーにおいて、これらのスコープは最も厳格な**「制限付きスコープ(Restricted Scopes)」**に分類されています。

調査して分かった現実は、次のようなものでした。

  1. 未検証状態では最大100人のテストユーザーしか使えない。 Google のアプリ確認(OAuth Verification)を通過していないアプリは、開発者がテストユーザーとして登録した Google アカウント(上限100人)しかログインできず、App Store で一般に配布しても誰も使えません。
  2. 一般公開には Google の人間による審査が必須。 アプリのデモ動画の提出、利用目的の正当性の証明、プライバシーポリシーの厳格な審査をクリアしなければなりません。
  3. 外部サーバーと通信する場合は年1回の第三者セキュリティ監査が必要。 もしユーザーのメールデータを受信メールサーバーや自前のバックエンドに送信・保存する場合、Google が指定する認定監査機関(Tier 2 / CASA)による年次セキュリティ評価を受ける必要があります。この監査費用は数十万円から数百万円規模に達し、個人開発の規模で毎年払い続けるのは不可能です。

端末内(ローカル)だけでメールを処理し、外部サーバーへデータを一切送らない構成にすればセキュリティ評価の免除を受けられる余地はありましたが、それでも Google の審査自体に数週間から数ヶ月を要します。

「審査を待っているあいだ、アプリを誰にも触ってもらえないのは避けたい」

そう考えた私は、決定事項 D-025 として Gmail 連携の優先度を下げ、審査なしで誰でも一般公開できる標準プロトコル——iCloud メール(IMAP / SMTP)への対応を先に進める ことを決めました。

なぜライブラリを使わず、IMAP / SMTP / MIME を自作したのか

iOS / Android 向けのクロスプラットフォーム開発には Expo(React Native)を採用していました。しかし、React Native のエコシステムを見渡しても、モバイル環境でまともに動く IMAP / SMTP クライアントライブラリはほぼ存在しませんでした。

Node.js 向けには node-imap や nodemailer といった枯れた定番ライブラリが存在しますが、それらは Node.js 固有の net モジュールや Node ストリーム、C++ バインディングに依存しています。React Native の JavaScript エンジン(Hermes)上ではそのまま動きません。

React Native 向けに移植されたラッパーライブラリもいくつか見つかりましたが、メンテナンスが数年前に止まっていたり、iOS のバックグラウンドや暗号化通信(STARTTLS)に対応していなかったりしました。

そこで、決定事項 D-026 として次の設計方針を打ち立てました。

「OSネイティブの raw ソケット通信だけを react-native-tcp-socket に任せ、その上で喋る IMAP クライアント、SMTP クライアント、MIME パーサーはすべて自前の純粋な TypeScript で書く」

プロトコル処理を React Native やネイティブモジュールから切り離し、純粋な TypeScript の文字列・バイト列操作として実装すれば、Jest を使って PC 上で一瞬でテストを回せます。外部サーバーと繋がなくても、メモリ上のモックソケットを使って RFC 仕様どおりに動くかを 100% 検証できるのが決め手でした。

日本のビジネスメールが突きつける「文字コードの洗礼」

自前でメールスタックを書き始めて最初に直面したのが、古くから残るメール特有の文字コードとヘッダー規約の洗礼でした。

1. Hermes の TextDecoder は ISO-2022-JP に対応していない

日本のビジネスメールでは、歴史的経緯から JIS コード(ISO-2022-JP)や Shift_JIS でエンコードされたメールが今でも日常的に届きます。

モダンなブラウザの TextDecoder API は ISO-2022-JP を標準でデコードできますが、React Native が採用している JavaScript エンジン「Hermes」の組み込み TextDecoder は、UTF-8 などごく一部のエンコーディングしかサポートしていません。new TextDecoder('iso-2022-jp') と書いた瞬間に Hermes は未対応エラーを吐いて落ちます。

そのため、文字コードの判別と変換には encoding-japanese ライブラリを組み込み、ソケットから流れてくる raw な Uint8Array を一度バイト列として受け取ってから、文字コードを判定して UTF-8 の文字列へと安全に変換するデコードパイプラインを構築しました。

2. encoded-word の75文字制限と、45バイトごとの件名折り返し

メールを送信する際、日本語の件名(Subject)は RFC 2047 に従い、=?UTF-8?B?...?= という形式(encoded-word)で Base64 エンコードして送信する必要があります。

しかし、RFC 2047 には**「1つの encoded-word は、改行を含めて 75 文字を超えてはならない」**という厳格な長さ制限があります。長い日本語の件名をそのまま丸ごと Base64 化して1行で送ると、受信側の古いメールサーバーや Outlook などで件名が途中で文字化けしたり、ヘッダーが壊れたりします。

Base64 は3バイトの元データを4文字のテキストに変換するため、Base64文字列を75文字以内に収めるには、元の UTF-8 文字列を 45 バイト以下ごとに正確に分割して折り返す(フォールディングする) 必要があります。

ここで文字列の文字数(string.length)でナイーブに切ってしまうと、日本語のマルチバイト文字(3バイト)の途中でぶった切ってしまい、文字化けの原因になります。バイト長を意識しながら文字の境界で安全に 45 バイトずつ分割し、それぞれを Base64 化して \r\n (CRLF + 空白)で連結するヘッダー生成ロジックを実装しました。返信スレッドを維持するための References ヘッダーも同様に、メッセージIDが長くなったときに1つずつ改行して折り返す処理を徹底しました。

3. RFC 2231 の日本語添付ファイル名

添付ファイルの名前も一筋縄ではいきません。添付ファイル名が長い場合や日本語を含む場合、モダンなメーラーは RFC 2231 という規格に従って次のような形式でヘッダーを出力してきます。

Content-Disposition: attachment;
 filename*0*=UTF-8''%E5%AD%A6%E7%94%9F%E8%A8%BC;
 filename*1*=%2Epdf

filename*0*、filename*1* のように番号付きで分割され、URLエンコードされたファイル名をパーサー側で順番通りに繋ぎ合わせ、パーセントデコードして元の「学生証.pdf」を復元する MIME 解析器(mime.ts)を書き上げました。作成したパーサーは、架空の .eml サンプル17件を用いて、業界標準の postal-mime ライブラリの解析結果と完全に一致することをテストで確認しました。

ソケット通信で踏んだ罠:STARTTLS の二重受信とあいさつのレース

MIME パーサーと並行して進めた IMAP / SMTP 通信クライアントでも、低レベルなソケットプログラミングならではの罠を踏みました。

STARTTLS 切り替え時の二重受信バグ

メールの暗号化通信には、最初からポート993で暗号化接続する「SSL/TLS 方式」と、最初は平文で接続し、途中で STARTTLS コマンドを送って暗号化接続へ昇格させる方式があります。

React Native の TCP ソケットライブラリ react-native-tcp-socket を使って STARTTLS への昇格処理を実装していたとき、サーバーから届いた受信データが全く同じ内容で2回重複してコールバックに流れてくるという不可解な現象に遭遇しました。

原因を追究したところ、ライブラリの内部実装にありました。react-native-tcp-socket は、平文ソケットを TLS ソケットにアップグレードした際、内部で平文用のデータ受信リスナーと、新しく作られた TLS 用のデータ受信リスナーの両方を生かしたままにしていました。そのため、ネットワークから届いた1つのパケットが両方の経路から同時に発火していたのです。

この問題は、STARTTLS が完了した瞬間に平文側の data リスナーを明示的に破棄し、TLS ソケット側のリスナーへと購読を確実に付け替えるハンドオフ処理を自前で挟むことで解決しました。

偽サーバーのあいさつ取りこぼし

単体テストのために、Node.js の net モジュールを使って IMAP サーバーの挙動(リテラル文字列の送信、+ による継続要求、STARTTLS のハンドシェイク)を再現するモックサーバーを作りました。

しかし、テストを実行すると時々タイムアウトでテストが落ちることがありました。調べてみると、クライアントがソケットの connect イベントを受け取り、データ受信リスナー(on('data'))を登録し終えるよりも前に、接続直後のモックサーバーが * OK IMAP4rev1 Server Ready という初期グリーティングを送信してしまっていたのです。

わずか数ミリ秒のレースコンディションによって最初のあいさつを取りこぼし、クライアントはサーバーからの返事を待ち続け、サーバーはクライアントからのコマンドを待ち続けるデッドロックに陥っていました。モックサーバー側でクライアントからの受信準備完了シグナルを待つか、接続直後のデータをバッファリングしてから流すように改善し、100回連続でテストを回しても1回も落ちない安定性を確保しました。

標準プロトコルを自前で持つ強さ

こうして完成した自作の通信スタックにより、MelTalk は Google の審査を待つことなく、Apple の iCloud メールアカウント(アプリ用パスワード)を使って実機でメールの送受信ができるようになりました。パスワードは iOS の Keychain(セキュアエンクレーブ)に暗号化して保存し、端末外へは一切送信しません。

もし最初から「Gmail API しか使えない前提」でアプリを組んでいたら、Google の審査要件に振り回され、仕様変更のたびに身動きが取れなくなっていたはずです。

低レベルな IMAP や SMTP、MIME という半世紀近く前から使われている標準プロトコルを自分たちの手で実装したことで、「どのメールサーバーと通信し、どの暗号化を施し、どうデータを保護しているか」を1バイト単位ですべて説明できる状態になりました。

この透明性と自律性こそが、個人のプライバシーの塊であるメールを扱うアプリにとって、何よりの信頼の土台になると信じています。

このノートに出てくるプロダクト

ほかのノート

ノートの一覧に戻る