Gmail APIのインライン画像が表示されない?MIMEパート構造とcid参照を理解して正しく表示する

目次

Gmail APIでメール本文を取得したときに起きること

Gmail APIの users.messages.get でメールを取得し、本文をWebアプリ上に表示する。

一見シンプルに思えるこの処理で、多くの開発者が同じ壁にぶつかる。

本文中に貼り付けられた画像が表示されない。

HTMLソースを確認すると、画像の src 属性に cid:image001.png@01D... のような見慣れない値が入っている。これはメール特有の画像参照方式で、ブラウザ上ではそのままでは解決できない。結果として画像は壊れた状態で表示されるか、[cid:xxx...] という文字列がそのまま本文に現れることになる。

この問題を正しく解決するには、Gmail APIが返すメールデータの構造そのものを理解する必要がある。

MIMEパート構造とは何か

Gmail APIは、メール本文を単一のフィールドで返さない。メールのMIME構造をそのまま payload.parts としてネストされたツリー構造で返す。

どこに本文が入っているかは、メールの構成によって変わる。

「MIME(マイム)タイプ」とは、インターネット上で扱うファイルの種類を識別するための情報です。

主にメールのヘッダーや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”
Gmail API — メール構成パターンとMIMEネスト構造 シンプル payload text/plain HTML対応 multipart/alternative text/plain text/html 添付あり multipart/mixed multipart/alternative text/html text/plain 📎 PDF等 インライン画像あり multipart/related multipart/alternative text/plain text/html (cid:xxx) ↑ img src=”cid:…” で参照 🖼 image/png (Content-Id付き) インライン+通常添付 multipart/mixed multipart/related multipart/alternative text/plain text/html (3階層下) 🖼 image/png (インライン) 📎 application/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エンコードされた画像データ)に置換することになる。

処理の流れ

  1. 添付ファイル一覧から isInline === true かつ contentTypeimage/* のものを特定する
  2. 各インライン画像の contentIdcontentBytes(base64データ)を取得する
  3. 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
}

cid: → data: URI 置換の流れ 置換前(ブラウザで表示不可) HTML本文 <img src=”cid:image001@01DA…”> 添付パート Content-Id: <image001@01DA…> cid と Content-Id を突き合わせ 置換後(ブラウザで表示可能) HTML本文 <img src=”data:image/png; base64,iVBORw0KGgo…”> ブラウザ表示結果 画像が壊れて表示 ブラウザ表示結果 画像が正常に表示

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-DispositionContent-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 かつ contentTypeimage/* に限定し、非画像のインライン添付は一覧に残すのが安全なアプローチになる。

// 添付ファイル一覧表示用のフィルタ
const displayAttachments = attachments
  .filter(a => !(a.isInline && a.contentType?.startsWith('image/')))

MS Graph APIとの違い

同じ「メールの本文と添付ファイルを取得する」という処理でも、Gmail APIとMS Graph APIではアプローチが大きく異なる。

Gmail APIMS 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 vs MS Graph API — 本文取得の違い Gmail API MIMEパート構造をそのまま返す multipart/mixed multipart/related multipart/alternative text/html(本文) → 自前で再帰的に掘り出す MS Graph API 解析済みのフラットな構造で返す body.content: “HTML本文” ← そのまま使える bodyPreview: “テキスト要約” hasAttachments: true → MIME解析はAPI側が処理済み

本文取得ロジックを実装するときに意識すること

Gmail APIを使ってメール本文を正しく取得・表示するためのポイントを整理する。

text/html パートの探索は再帰で行う

最上位の parts だけを見る実装では、添付ファイル付きやインライン画像付きのメールで本文が取得できない。mimeTypetext/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の両方を扱う場合、レスポンス構造が根本的に異なるため、共通のインターフェースに変換するアダプター層を設けるのが定石になる。変換時に isInlinecontentId などのインライン画像に関わるフィールドを落とさないよう注意が必要。特にGmail API側では、パートの headers からこれらの値を自前で抽出する処理を忘れやすい。

目次