😇

Laravel 12の認証メールが“生HTML表示”になる原因と解決 — Markdown通知からカスタムHTMLへ

に公開

この記事で解決できること

  • 認証メールの本文に <table class="action" ...><a href="..."> などの“生HTML”がそのまま表示される
  • メールクライアントやプレビューによって、ボタンがテキスト化・崩れる
  • Markdownベースの通知テンプレートを使った際の表示ゆれをなくしたい

[!NOTE]
本記事は Laravel 12 + Inertia.js のプロジェクトで遭遇した事象をもとにしています。結論として「通知を完全なHTMLビューで送る」方式に切り替えると安定します。


結論(TL;DR)

  • 原因: Laravelの「Markdown通知メール」テンプレート(<x-mail::message>)に生HTML(独自の <a> やテーブル)を混在させると、レンダラや一部メーラーがエスケープし、本文にHTML文字列が露出することがある
  • 解決: Markdown通知をやめて、通知クラスから完全なHTMLビューを指定して送信する
  • 効果: ほぼすべてのメーラーで「ボタン付きの日本語メール」が安定表示される

症状の例

  • 認証メール本文に以下のような文字列がそのまま出る
<table class="action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
  ...
</table>
<a href="https://..." style="...">メールアドレスを認証する</a>
  • クライアントやプレビュー(ログ出力ビューア等)により、ボタンが出たり出なかったりする

[!WARNING]
Markdown通知は“Markdownとして安全に書く”のが前提です。生HTMLを混ぜると、テキスト版生成時やエスケープ処理で崩れるリスクがあります。


解決策の全体像

  1. Userモデルで、デフォルトのメール確認通知をカスタム通知に差し替える
  2. カスタム通知クラスで toMail()HTMLビュー送信に変更
  3. 独自のHTMLメールテンプレート(インラインCSS)を用意

実装手順

1) Userモデルで通知を差し替え

// app/Models/User.php(抜粋)
use App\Notifications\CustomVerifyEmail;

public function sendEmailVerificationNotification()
{
    $this->notify(new CustomVerifyEmail);
}

2) カスタム通知クラス(HTMLビューを使う)

// app/Notifications/CustomVerifyEmail.php(抜粋)
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;

class CustomVerifyEmail extends VerifyEmail
{
    public function toMail($notifiable): MailMessage
    {
        $verificationUrl = $this->verificationUrl($notifiable);

        return (new MailMessage)
            ->subject('メールアドレスの認証をお願いします')
            ->view('mail.verify-email', [
                'actionUrl' => $verificationUrl,
                'appName'   => config('app.name'),
            ]);
    }
}

3) HTMLメールテンプレートを作成

  • ファイル: resources/views/mail/verify-email.blade.php
  • ポイント: メール互換性のためテーブルレイアウトインラインCSS
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="x-ua-compatible" content="ie=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>{{ $appName }} - メール認証</title>
    </head>
    <body style="margin:0;padding:0;background:#f3f7fb;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;">
        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#e9eff5;padding:24px 0;">
            <tr>
                <td align="center">
                    <table
                        role="presentation"
                        width="600"
                        cellpadding="0"
                        cellspacing="0"
                        style="background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(15,23,42,0.08);"
                    >
                        <tr>
                            <td style="padding:24px 28px;">
                                <h1 style="margin:0 0 8px 0;font-size:20px;">こんにちは!</h1>
                                <p style="margin:0 0 16px 0;line-height:1.7;">
                                    {{ $appName }} にご登録いただき、ありがとうございます。<br />
                                    以下のボタンをクリックしてメールアドレスの認証を完了してください。
                                </p>
                                <table role="presentation" align="center" cellpadding="0" cellspacing="0" style="margin:24px auto;">
                                    <tr>
                                        <td align="center" bgcolor="#0ea5e9" style="border-radius:8px;">
                                            <a
                                                href="{{ $actionUrl }}"
                                                style="display:inline-block;padding:12px 24px;color:#ffffff;text-decoration:none;font-weight:700;"
                                            >
                                                メールアドレスを認証する
                                            </a>
                                        </td>
                                    </tr>
                                </table>
                                <p style="margin:0 0 8px 0;line-height:1.7;">このメールに心当たりがない場合は、何もする必要はありません。</p>
                                <p style="margin:16px 0 0 0;line-height:1.7;">よろしくお願いいたします。<br />{{ $appName }}</p>
                            </td>
                        </tr>
                        <tr>
                            <td style="padding:16px 28px;background:#f8fafc;border-top:1px solid #e2e8f0;">
                                <p style="margin:0; font-size:13px; color:#475569;">
                                    ボタンがクリックできない場合は以下のURLをブラウザに貼り付けてください:<br />
                                    <a href="{{ $actionUrl }}" style="color:#0ea5e9;word-break:break-all;">{{ $actionUrl }}</a>
                                </p>
                            </td>
                        </tr>
                    </table>
                </td>
            </tr>
        </table>
    </body>
</html>

よくある落とし穴と対策

  • 落とし穴: Markdown通知テンプレートに生HTMLを混ぜる
    • 対策: すべてMarkdownコンポーネントで統一するか、今回のようにHTMLビュー送信へ切替
  • 落とし穴: テキスト版での可読性が崩れる
    • 対策: HTMLビューに加えて、必要に応じてテキスト版テンプレートも用意
  • 落とし穴: 本番でURLがおかしい
    • 対策: .envAPP_URL を本番ドメインに、MAIL_FROM_NAME を日本語名に設定

[!TIP]
運用では SPF/DKIM/DMARC のセットアップも忘れずに。配信到達率や迷惑メール判定に影響します。


テスト観点(チェックリスト)

  • 新規登録時に自動で認証メールが届く
  • HTML版でボタンが表示され、テキスト版にも最低限の導線がある
  • 主要メーラー(Gmail, iCloud, Outlook など)で崩れない
  • 期限切れURLや多回送も想定して再送機能を確認

まとめ

  • Markdown通知は便利だが、生HTML混在で“生HTML表示”になることがある
  • 最も安定するのは「通知→HTMLビュー送信」
  • 本記事の実装で、日本語のボタン付きメールがほぼすべてのメーラーで安定表示されるようになる

[!NOTE]
プロジェクトでは Google 認証(Socialite)とも併用し、登録導線はメール・Googleともに同じ認証ページで統一しています。運用体験も揃えられておすすめです。

Discussion