[Python] GmailでHTMLメールの画像が表示されない問題と解決法【Python実装】

はじめに

学生時代、Perlで作成した自動化スクリプトを10年ぐらい前にPythonへ移植しFreeBSDやLinux上で動作させていましたが、 2年ほど放置していた間にGmailの仕様が変わったのか、画像が

「PCでは見えるのにiPhoneでだけ表示されない」

問題を解決したので、備忘録情報をまとめてみました。

1. 症状まとめ

HTMLメールに商品画像を埋め込んで送信したところ、以下のような不思議な挙動が発生しました。

  • 表示されるときと表示されないときがある PCブラウザ版Gmailでは普通に表示されるのに、iPhoneのGmailアプリでは画像が見えないケースが頻発。

  • 本文ではなく「添付ファイル」として扱われることがある 本来は本文内に表示させたいのに、メール末尾に「添付」としてだけ出てしまう。

  • 本文に <img> タグがそのまま文字列で表示されることがある text/plain パートにHTMLタグが混ざってしまい、そのまま出力されてしまった。

  • 転送すると画像が突然表示される 送信直後の受信メールでは画像が出なかったのに、「転送」画面を開くと画像が正しくプレビューされる。 → Gmailが転送用に本文を再レンダリングし、Googleの画像プロキシを通したためと考えられる。

  • 「自動サイズ変更を元に戻す」と出ることがある iOS Gmailでは幅指定がない画像を勝手に縮小表示し、「自動サイズ変更を元に戻す」ボタンで本来サイズに戻る動作をする。

2. 調査でわかったこと

  • GmailはPCとiOSで挙動が違う PC版のGmail(Webブラウザ)はほぼ常に画像を表示できるのに対し、iOS版のGmailアプリでは表示されないケースが多い。 → メールのレンダリングエンジンが別で、挙動も異なる。

  • 外部画像の取得方法が混在している PCでは Google の画像プロキシ経由で取得するため、表示できず。 一方、iOS Gmailは状況によって端末から直接取得しに行くため、ホットリンク制限やUA/Refererチェックでブロックされて表示されない。

  • text/plain に HTMLタグが残ると「タグ文字列」として出てしまう 本来はプレーンテキスト用にタグを除去する必要があるが、その処理をしないと <img src=...> が文字列としてそのまま表示される。

  • width 指定がないと iOS Gmail が勝手に縮小プレビューする 画像サイズがバラバラな場合、スマホ版Gmailが自動で小さく表示してしまい「自動サイズを元に戻す」ボタンが出る。 → <img>width を明示し、height:auto を指定することで安定して本文に表示できた。

3. 解決のポイント

試行錯誤の末、以下の実装ルールに揃えることで、iOS Gmailでも安定して画像が表示されるようになりました。

  • 画像は CID インライン添付(multipart/related 外部URLをそのまま埋め込むのではなく、送信時に画像を取得してインライン添付し、<img src="cid:..."> として本文に埋め込む。 → これで外部サーバのホットリンク規制や取得失敗の影響を受けなくなる。

  • <img> タグはサイズを明示 width を指定し、height:auto + max-width:100% を組み合わせることで、PCでもスマホでもレイアウトが崩れにくくなった。

  • 余計な属性は削除 loading="lazy"class="..." は Gmail のHTMLサニタイザに引っかかりやすく、表示が不安定になるため削除。

  • text/plain は素のテキストだけにする タグを除去して本文テキストのみを残す。これを忘れると、プレーン部分に <img> タグがそのまま出力され「タグ文字列」として見えてしまう。

  • 画像取得失敗時は外部URLのままにフォールバック 添付が一枚もできなかった場合は HTML を書き換えずに送信し、少なくともPCのGmailで表示されるようにする。

  • 件名はユニーク化してスレッドキャッシュ回避 Gmailは同一件名のメールを同じスレッドでまとめるため、過去の「画像が表示されない状態」をキャッシュして引きずることがある。 件名にタイムスタンプやシリアル番号を付けて毎回ユニーク化することで、キャッシュの影響を防げる。

4. 最終的なPythonコード(抜粋)

以下は実際に安定して画像を本文に埋め込めた最小構成の例です。

ポイントは multipart/related をルートにして、その中に multipart/alternative(plain+html)を入れ、画像を CID 添付することです。

環境

  • OS: Debian GNU/Linux 12 (Kernel 6.1.0-37-amd64)
  • Python: 3.11.2
  • 使用ライブラリ: 標準ライブラリ (smtplib, email.mime) のみ
  • SMTPサーバ: ISP提供の smtp (ポート465, SSL)
  • 確認クライアント:
    • Gmail (PC / Chrome ブラウザ版)
    • Gmail (iOSアプリ)
import ssl, smtplib, re, urllib.request
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage

SMTP_HOST = "---"
SMTP_PORT = 465
SMTP_USER = "---"
SMTP_PASS = "---"

# プレーンテキスト生成(タグを削除)
def make_plain(html: str) -> str:
    text = re.sub(r'<br\s*/?>', '\n', html, flags=re.I)
    text = re.sub(r'<[^>]+>', '', text)
    return text.strip()

# 外部URLから画像を取得
def fetch_image(url: str):
    req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0"})
    with urllib.request.urlopen(req, timeout=10) as r:
        data = r.read()
        ctype = r.info().get_content_type() or "image/jpeg"
    return data, ctype.split("/")[-1]

def sendmail(From, To, subject, html):
    # ルート multipart/related
    root = MIMEMultipart('related')
    root["From"] = From
    root["To"] = To
    root["Subject"] = subject

    # multipart/alternative(plain + html)
    alt = MIMEMultipart('alternative')
    alt.attach(MIMEText(make_plain(html), 'plain', 'utf-8'))
    alt.attach(MIMEText(html, 'html', 'utf-8'))
    root.attach(alt)

    # <img src="..."> を cid に置換して添付
    pattern = re.compile(r'<img\s+[^>]*src=["\']([^"\']+)["\']', re.I)
    cid_idx = 1
    def repl(m):
        nonlocal cid_idx
        url = m.group(1)
        try:
            data, subtype = fetch_image(url)
        except Exception:
            return m.group(0)  # 取得失敗→そのまま
        cid = f"img{cid_idx}"
        img = MIMEImage(data, _subtype=subtype, name=f"{cid}.{subtype}")
        img.add_header('Content-ID', f'<{cid}>')
        img.add_header('Content-Disposition', 'inline', filename=f'{cid}.{subtype}')
        img.add_header('X-Attachment-Id', cid)  # iOS Gmail対策
        root.attach(img)
        cid_idx += 1
        return m.group(0).replace(url, f"cid:{cid}")
    new_html = pattern.sub(repl, html)

    # htmlパートを差し替え(添付できたときのみ)
    if cid_idx > 1:
        alt.get_payload()[-1] = MIMEText(new_html, 'html', 'utf-8')

    # 送信
    with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ssl.create_default_context()) as s:
        s.login(SMTP_USER, SMTP_PASS)
        s.sendmail(From, [To], root.as_string())

コードのポイント

  • ルートは multipart/related
  • 本文は multipart/alternative(text/plain と text/html を両方入れる)
  • 画像は Content-ID を付けて添付し、本文から img src=“cid:…” で参照
  • plain 部分にはタグを残さない → 「タグ文字列が本文に出る問題」を回避
  • width + height:auto + max-width:100% を img に指定して、スマホでも安定表示

5. まとめ

  • Webのレスポンシブ知識だけでは解決しない ブラウザなら動くコードでも、メールクライアント(特にiOS Gmail)は独自の制限やサニタイザが働くため、そのままでは表示されないことが多い。

  • 「HTMLメール=独自の古いブラウザ」と思って作る 余計な属性やCSSは極力避け、シンプルに <table> ベースのレイアウト+<img> の width/height 明示が鉄則。 最新のWeb技術(loading="lazy"や複雑なclass指定)は逆に不具合を生む。