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” |
text/html パートの探索は再帰的に確認する必要がある
最上位の parts だけを見る実装では、添付ファイル付きやインライン画像付きのメールで本文が取得できない。mimeType が text/html のパートが見つかるまで再帰的に parts を掘る実装が必要になる。

Content-Type: text/plain と text/html とは
メールの本文がどういう形式のデータかを受信側に伝えるための宣言。
HTTPのContent-Typeと同じ仕組みで、MIME(Multipurpose Internet Mail Extensions)という規格で定義されている。
text/plainは「装飾なしの生テキスト」。
受信側のメールクライアントはそのまま等幅フォントなどで表示する。
太字、色、リンクのクリック、画像の埋め込みなどは一切できない。text/htmlは「HTMLマークアップされたテキスト」。
受信側はブラウザと同じようにHTMLをレンダリングする。<b>,<a href>,<img>,<table>などが使える。現代のメールクライアント(Gmail, Outlook, Apple Mailなど)はほぼすべてHTML表示に対応している。
プレーンテキスト(text/plain)とは何か
プレーンテキストとは、装飾情報を一切持たない純粋な文字列データのこと。
太字もリンクもフォント指定もない。あるのは文字と、制御文字(改行・タブなど)だけ。
制御文字とは、
画面上に「文字」として直接表示されるのではなく、テキストの配置や区切りをコンピューターに指示するための特殊なデータのことです。
改行(Enter)、タブ(Tab)など
文字データ(「あ」「A」「1」など)
│
▼
制御文字(改行・タブなど) を組み合わせる
│
▼
「プレーンテキスト」完成
│
▼
HTMLタグ(<b>や<a>など)で装飾する?
│
├──[No]──> コンテンツタイプを「text/plain」に指定する
│ │
│ ▼
│ ブラウザへ送信
│ │
│ ▼
│ 装飾なしでそのまま表示される(文字と改行のみ)
│
│[Yes]
▼
「HTML」完成(プレーンテキスト + HTMLタグ)
│
▼
コンテンツタイプを「text/html」に指定する
│
▼
ブラウザへ送信
│
▼
ブラウザがタグを解釈し、装飾して表示する(太字やリンクなど)
改行コードの話
「改行は \n なのか?」という疑問はもっともで、実は環境によって異なる。
| 表記 | 意味 | 使われる環境 |
|---|---|---|
\n(LF) | Line Feed | Linux, macOS, Web |
\r\n(CRLF) | Carriage Return + Line Feed | Windows, メールのMIME仕様(RFC 2822) |
\r(CR) | Carriage Return | 古いMac(OS 9以前)。現在はほぼ見ない |
重要なのは、メールの仕様上、改行コードは \r\n(CRLF)と定められているという点。
RFC 2822 で「本文の各行はCRLFで終端する」と明記されている。
ただし実際には、Gmail APIが返す body.data をbase64urlデコードした結果が厳密にCRLFかどうかはメール送信元の実装に依存する。改行コードで split する処理を書くときは注意が必要になる。
// 安全な改行分割
const lines = text.split(/\r?\n/)
text/plain パートをWebアプリで表示するときの落とし穴
プレーンテキストをそのまま innerHTML や dangerouslySetInnerHTML で流し込むと、改行が無視される。HTMLでは \n はホワイトスペースとして折りたたまれるため。
- CSSで
white-space: pre-wrapを指定する — 改行と空白がそのまま反映される \nを<br>に置換してからHTMLとして挿入する
// 方法1 CSS
<div style={{ whiteSpace: 'pre-wrap' }}>{plainText}</div>
// 方法2 置換(dangerouslySetInnerHTMLと組み合わせる場合)
const toDisplayHtml = (text: string) =>
text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\n/g, '<br>')
RFC の行長制限とは
RFC 5322(RFC 2822の後継)のセクション2.1.1で、メール本文の各行はCRLFを除いて998文字以内でなければならない(MUST)、78文字以内が推奨(SHOULD)と定められている。 IETF
998文字の制限は、メッセージを送受信・保存する多くの実装が1行998文字を超えると処理できないために存在する。78文字の推奨は、メッセージを表示するUIが長い行を切り詰めたり崩して表示してしまう可能性があるために設けられている。 Go2Share
これはインターネットが低帯域・低スペックだった時代からの制約で、メールが中継される途中のサーバー(MTA)のバッファサイズに由来する。現代でもRFCとしては有効な規定。
text/plain で何が起きるか
text/plain の場合、メール本文は「ただのテキスト」として扱われる。つまり改行文字(CRLF)がそのまま「ここで行が終わる」という意味になる。
問題はこうなる:
1. MTA による強制改行 ユーザーが改行せずに長い文章を1行で書いた場合(日本語だと特に起きやすい。スペースが少ないため1段落が1行になりがち)、998文字を超える行が発生する。するとメールサーバーやMTA(Sendmailなど)が規格違反を防ぐために勝手にその行を途中で折り返す。実際にSendmailは997文字の位置で行を分割し、! + CRLFを挿入する実装になっている。受信側では意図しない位置に改行が入った状態で表示される。 cmu
2. 意図した改行 vs 強制改行の区別がつかない text/plain では、すべての改行が同じCRLFで表現される。送信者が意図的に入れた改行なのか、MTAが行長制限のために挿入した改行なのか、受信側のクライアントには判別する方法がない。
3. format=flowed(RFC 3676)という解決策はあるが不完全 RFC 3676は、text/plain のまま行の折り返しを制御できる format=flowed パラメータを定義している。これは「行末にスペースがある行は次の行と連結してよい(soft break)」「スペースがない行末は本当の改行(hard break)」というルールで区別するもの。 IETF
- GmailのWebUIは
format=flowedを無視してハード改行として表示してしまう、という実装上の問題がある。 Cpbotha - 日本語のように単語間にスペースがない言語では、700〜1200文字のスペースなし段落に対して適切な位置で改行を入れられず、1行が数百文字になってしまい、行折り返し機能を持たないメールクライアントではまともに表示できない。 Mozilla Bugzilla
つまり format=flowed は英語圏ではそこそこ機能するが、日本語では根本的に相性が悪く、かつ主要クライアントの対応もまちまちという状況。
なぜ text/html なら問題を回避できるか
HTMLの場合、改行の扱いが根本的に違う。HTMLでは <br> や <p> が明示的な改行を意味し、ソースコード上の改行文字(CRLF)は単なるホワイトスペースとして無視される。
つまり
- MIMEレベルでは998文字制限に従うためにソースに改行が挿入されても、HTMLレンダリング時にはそれが無視される
- 表示上の改行は
<br>タグだけで決まるので、「意図した改行」と「規格上の行分割」が混同されない - Transfer-Encodingとして
base64やquoted-printableを使えば、そもそもバイナリレベルで行長を自動管理できる
逆にHTMLからプレーンテキストへの変換
HTMLからプレーンテキストへの変換にはHTMLエンティティ(HTML実体参照)のデコード処理が必要
HTMLエンティティとは
HTMLでは、一部の文字がマークアップの構文として特別な意味を持つ。たとえば < はタグの開始を意味する。だから本文中に「<という文字そのもの」を書きたい場合、そのまま書くとブラウザがタグだと誤解する。
そこで < のようなエスケープ表記(エンティティ)で代替する仕組みがある
| エンティティ | 元の文字 | なぜエスケープが必要か |
|---|---|---|
< | < | タグ開始と区別がつかなくなる |
> | > | タグ終了と区別がつかなくなる |
& | & | エンティティ自体の開始記号なので |
" | " | HTML属性値の区切りに使われるので |
' | ' | 同上 |
| (半角スペース) | 連続スペースを潰さない特殊スペース |
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
}
Microsoft Graph APIとの違い
同じ「メールの本文と添付ファイルを取得する」という処理でも、Gmail APIとMicrosoft Graph APIではアプローチが大きく異なる。
| Gmail API | Microsoft 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 内にそのまま存在 |
Microsoft Graph APIは、MIME構造の解析をAPI側で処理した上でフラットなレスポンスを返す設計になっている。Gmail APIはMIME構造をほぼそのまま返すため、クライアント側で構造解析を行う必要がある。
この違いは bodyPreview / snippet にも現れている。どちらもプレーンテキストの要約であり、HTMLタグや cid: 参照は含まれない点は共通しているが、名前もフィールドの位置も異なるため、両方のAPIを扱うアプリケーションではマッピング処理が必要になる。
Microsoftのメールのインライン画像のファイル名について
outlook.body-content-type は Graph API でメッセージの body を取得する際、レスポンスの本文形式を指定するヘッダーです。
指定できる値は2つ
Prefer: outlook.body-content-type="text"→body.contentがプレーンテキストで返るPrefer: outlook.body-content-type="html"→body.contentが HTML で返る
指定しなかった場合のデフォルトは HTML です。
なお、このヘッダーが影響するのは body.content のみで、bodyPreview は常にプレーンテキストです。
dangerouslySetInnerHTML
「どうしても生のHTMLを直接挿入したい」という場合のReactの組み込みの機能(プロップス)
const html = '<b>太字</b>と<a href="https://example.com">リンク</a>';
// dangerouslySetInnerHTMLなし
return <div>{html}</div>;
// 画面表示: <b>太字</b>と<a href="https://example.com">リンク</a>
// ↑ タグがそのまま文字として見える
// dangerouslySetInnerHTMLあり
return <div dangerouslySetInnerHTML={{ __html: html }} />;
// 画面表示: 太字とリンク
// ↑ タグがHTMLとして解釈され、太字やリンクになるcidとは
メール本文のHTMLには <img src="cid:image001.png"> のような画像参照が含まれています。これはメール内部でしか通用する識別子で、「このメールに添付されている画像ファイルを参照する」という意味です。ブラウザには解決できないURLなので、画像が表示できず破損アイコンになります。
「メール本文中のインライン画像はCIDというID で参照されており、現状そのIDが未解決のため画像が表示できていません。APIから該当の画像データを別途取得し、表示処理に組み込むことで対応可能です。」
画像キー(
String)から画像データ(BlobSource)へのマッピングを含む JavaScript オブジェクト。これは、htmlBodyパラメータが使用され、これらの画像への参照が<img src="cid:imageKey" />形式で含まれていることを前提としています。
https://developers.google.com/apps-script/reference/gmail/gmail-message?hl=ja
画像をメール本文に直接埋め込むには、HTML 属性の content-ID (CID) を使用します。
https://learn.microsoft.com/ja-jp/azure/communication-services/concepts/email/email-attachment-inline
本文表示についてGoogleとMicrosoftでスタイルが異なる
メール送信者のメーラーが生成するHTMLが異なるからです。
Outlookは font-size:11pt や font-family:Calibri のようなインラインスタイルを各要素に直接付与します。Gmailは比較的シンプルなHTMLを生成しますが、独自のデフォルトスタイルがあります。
現状の page.tsx では親要素に fontSize: '0.75rem' を指定していますが、メール本文中のインラインスタイル(style="font-size:11pt")の方がCSS詳細度が高いので、親の指定が上書きされます。
揃える方法はあります。 メール本文のHTMLからインラインの font-size を除去すればいい。
const normalizeMailHtml = (html: string): string => {
return html
.replace(/font-size\s*:\s*[^;"']+;?/gi, '')
.replace(/font-family\s*:\s*[^;"']+;?/gi, '')
}
これを replaceCidImages と合わせて適用すれば、親要素の fontSize: '0.75rem' が効くようになり、Gmail・Outlook どちらのメールも同じ文字サイズで表示されます。
ただし、送信者が意図的に文字サイズを変えている場合(強調のために大きくしたなど)もすべて統一されるので、そこはトレードオフです。