Gmail APIでメール本文を取得したときに起きること
Gmail APIの users.messages.get でメールを取得し、本文をWebアプリ上に表示する。
一見シンプルに思えるこの処理で、多くの開発者が同じ壁にぶつかる。
本文中に貼り付けられた画像が表示されない。
HTMLソースを確認すると、画像の src 属性に cid:image001.png@01D... のような見慣れない値が入っている。これはメール特有の画像参照方式で、ブラウザ上ではそのままでは解決できない。結果として画像は壊れた状態で表示されるか、[cid:xxx...] という文字列がそのまま本文に現れることになる。
この問題を正しく解決するには、Gmail APIが返すメールデータの構造そのものを理解する必要がある。

MIMEパート構造とは何か
Gmail APIは、メール本文を単一のフィールドで返さない。メールのMIME構造をそのまま payload.parts としてネストされたツリー構造で返す。
どこに本文が入っているかは、メールの構成によって変わる。
主にメールのヘッダーやHTTPレスポンスヘッダーに付与され、ブラウザやメールソフトに対して「そのファイルがどのようなデータ(テキスト、画像、動画など)であるか」を正確に伝える役割を持っています。
| メールの種類 | mimeType |
|---|---|
| テキストだけのシンプルなメール あまりユースケースでないですね、、 システム通知やCLIツールからの送信に多い、最もシンプルな構造。 payload.body.data に直接本文が入っている。 | payload mimeType: “text/plain” body.data: “本文(base64url)” |
| 一般的なメール(HTML対応) Outlook、Gmail、Thunderbirdなど、ほとんどのメールクライアントから送信されるメールはこの構造になる。 multipart/alternative の中に、プレーンテキスト版とHTML版が並列で格納されている。受信側のクライアントがどちらか読める方を選んで表示する仕組みだが、今どきのクライアントは基本的にHTML版を使う | payload mimeType: “multipart/alternative” parts: [0] mimeType: “text/plain”, body.data: “プレーンテキスト版” [1] mimeType: “text/html”, body.data: “HTML版” |
| 添付ファイル付きメール ファイルを添付すると、 multipart/mixed で全体が包まれ、その中に本文パート(multipart/alternative)と添付ファイルパートが並ぶ。本文は1階層深くなる。 | payload mimeType: “multipart/mixed” parts: [0] mimeType: “multipart/alternative” parts: [0] mimeType: “text/plain”, body.data: “プレーンテキスト版” [1] mimeType: “text/html”, body.data: “HTML版” [1] mimeType: “application/pdf”, filename: “資料.pdf” |
| インライン画像付きメール 本文中に画像を貼り付けたメール、または署名にロゴ画像を含むメールがこの構造になる。 multipart/related がインライン画像と本文をグループ化し、HTML本文は cid: で画像を参照する | payload mimeType: “multipart/related” parts: [0] mimeType: “multipart/alternative” parts: [0] mimeType: “text/plain”, body.data: “プレーンテキスト版” [1] mimeType: “text/html”, body.data: “HTML版(<img src=’cid:xxx’>)” [1] mimeType: “image/png”, filename: “image.png” |
| インライン画像+通常添付ファイル 最も深いネストになるパターン。署名にロゴが入った状態でPDFを添付して送信するような、業務メールでは日常的に発生する構造。本文のHTMLは3階層下に位置する | payload mimeType: “multipart/mixed” parts: [0] mimeType: “multipart/related” parts: [0] mimeType: “multipart/alternative” parts: [0] mimeType: “text/plain” [1] mimeType: “text/html” ← 3階層下 [1] mimeType: “image/png”, filename: “logo.png” [1] mimeType: “application/pdf”, filename: “見積書.pdf” |
cid参照の仕組み
cid は Content-ID の略で、メールのMIME仕様(RFC 2392)で定義された、メール内の添付リソースを参照するためのURIスキーム。
Webでの https:// と同じように、メールの世界では cid: が添付ファイルへのリンクとして機能する。
インライン画像を含むメールのHTML本文には、以下のような記述がある。
<img src="cid:image.png@01DA2B3C.4E5F6A70">
一方、同じメールの添付パートには Content-Id ヘッダーが設定されている。
Content-Type: image/png
Content-Id: <image.png@01DA2B3C.4E5F6A70>
Content-Disposition: inline
メールクライアントはこの cid: と Content-Id を突き合わせて、添付された画像データを本文中に描画する。
ブラウザはこの cid: スキームを解釈できないため、Webアプリ上で表示する場合は開発者が自前で解決する必要がある。
インライン画像をWebアプリ上で表示する方法
基本的なアプローチは、HTML本文中の cid: 参照を data: URI(base64エンコードされた画像データ)に置換することになる。
処理の流れ
- 添付ファイル一覧から
isInline === trueかつcontentTypeがimage/*のものを特定する - 各インライン画像の
contentIdとcontentBytes(base64データ)を取得する - HTML本文中の
src="cid:xxx"をsrc="data:image/png;base64,..."に文字列置換する
const resolveInlineImages = (html: string, attachments: Attachment[]) => {
let resolved = html
attachments
.filter(a => a.isInline && a.contentId && a.contentBytes
&& a.contentType?.startsWith('image/'))
.forEach(a => {
const cid = a.contentId.replace(/^<|>$/g, '')
resolved = resolved.replace(
new RegExp(`src=["']cid:${cid}["']`, 'gi'),
`src="data:${a.contentType};base64,${a.contentBytes}"`
)
})
return resolved
}
Gmail APIでの注意点
Gmail APIは、メール一覧取得時に画像のバイナリデータ(contentBytes)を返さない。body.attachmentId だけが格納されており、実際のデータは messages.attachments.get エンドポイントで別途取得する必要がある。
GET /gmail/v1/users/me/messages/{messageId}/attachments/{attachmentId}
レスポンスの data フィールドにbase64urlエンコードされた画像データが入るので、これをデコードして data: URIに使用する。
さらに、Gmail APIの添付パート情報からインライン画像を判定するには、パートの headers から Content-Disposition と Content-Id を取得する必要がある。パートの headers は配列形式で返されるため、以下のように探索する。
const contentDisposition = part.headers
?.find(h => h.name.toLowerCase() === 'content-disposition')?.value || ''
const contentId = part.headers
?.find(h => h.name.toLowerCase() === 'content-id')?.value || ''
const isInline = contentDisposition.startsWith('inline') && !!contentId
添付ファイル一覧の表示にも影響する
インライン画像は添付ファイルとしても存在しているため、フィルタリングせずに一覧表示すると、本文に埋め込まれた画像がダウンロード用の添付ファイルとして並んでしまう。
ただし isInline === true のものをすべて除外すると問題が起きる。Outlookなど一部のクライアントでは、PDFやExcelを本文にドラッグ&ドロップした場合にも isInline: true が設定されることがある。非画像ファイルは cid: で描画できないため、一覧からも本文からも消えてしまう。
フィルタ条件は isInline === true かつ contentType が image/* に限定し、非画像のインライン添付は一覧に残すのが安全なアプローチになる。
// 添付ファイル一覧表示用のフィルタ
const displayAttachments = attachments
.filter(a => !(a.isInline && a.contentType?.startsWith('image/')))
MS Graph APIとの違い
同じ「メールの本文と添付ファイルを取得する」という処理でも、Gmail APIとMS Graph APIではアプローチが大きく異なる。
| Gmail API | MS Graph API | |
|---|---|---|
| 本文の取得 | MIMEパート構造を自前で解析 | body.content に展開済みで格納 |
| 本文の場所 | パターンにより異なる(最大3階層下) | 常に body.content |
| 本文プレビュー | snippet(約200文字) | bodyPreview(約255文字) |
| インライン判定 | パートの headers から自前で判定 | isInline フラグがそのまま使える |
| 画像データ取得 | attachmentId で別途API呼び出し | contentBytes が一覧と一緒に返る |
| cid参照 | text/html パートの body.data 内(base64urlデコード後) | body.content 内にそのまま存在 |
MS Graph APIは、MIME構造の解析をAPI側で処理した上でフラットなレスポンスを返す設計になっている。Gmail APIはMIME構造をほぼそのまま返すため、クライアント側で構造解析を行う必要がある。
この違いは bodyPreview / snippet にも現れている。どちらもプレーンテキストの要約であり、HTMLタグや cid: 参照は含まれない点は共通しているが、名前もフィールドの位置も異なるため、両方のAPIを扱うアプリケーションではマッピング処理が必要になる。
本文取得ロジックを実装するときに意識すること
Gmail APIを使ってメール本文を正しく取得・表示するためのポイントを整理する。
text/html パートの探索は再帰で行う
最上位の parts だけを見る実装では、添付ファイル付きやインライン画像付きのメールで本文が取得できない。mimeType が text/html のパートが見つかるまで再帰的に parts を掘る実装が必要になる。
const findHtmlBody = (parts: Part[]): string | null => {
for (const part of parts) {
if (part.mimeType === 'text/html' && part.body?.data) {
return decodeBase64url(part.body.data)
}
if (part.parts) {
const found = findHtmlBody(part.parts)
if (found) return found
}
}
return null
}
テスト時は複数パターンのメールを用意する
前述のMIMEパート構造のパターンごとにテストメールを用意し、それぞれで本文が正しく取得・表示されることを確認する。テストメールの本文は300文字以上にしておくと、snippet(約200文字で切られる)へのフォールバックが発生した場合に気づきやすい。本文の末尾に目印となるテキストを入れておけば、末尾が表示されているかどうかで判別できる。
両APIを扱うアプリではアダプター層を設ける
Gmail APIとMS Graph APIの両方を扱う場合、レスポンス構造が根本的に異なるため、共通のインターフェースに変換するアダプター層を設けるのが定石になる。変換時に isInline、contentId などのインライン画像に関わるフィールドを落とさないよう注意が必要。特にGmail API側では、パートの headers からこれらの値を自前で抽出する処理を忘れやすい。