IT系おじさんのチラシの裏
2018年10月~
当サイトの記事にはアフィリエイト広告のリンクが含まれる場合があります

PHPで文字化けしないHTMLメールを送信するために必要な知識とサンプルコード

メールを送信するプログラムは何度も書いて、実際に運用もしてきていますが、だいたいは受信する環境がわかっている(例えばGmailで受信するとあらかじめ知っている等)状況でした。

しかし、不特定多数の様々な環境の人へメールを送る必要に迫られ、あらためて文字化けとの戦いが勃発し、あー、自分なんも理解してなかったッスわー、と今更ながら気が付いたので、HTMLメール送信で学んだことを備忘録としてまとめておきます。

メール送信で特に気を付けるべき仕様

基本的にはRFC5322RFC2045に従えば良いのですが、いちいち読んでられんって人も多いと思うので、特にハマりがちなところを抜粋します。

メールの1行あたりの長さ制限

  • 1行の文字数は998文字を超えてはならない (MUST)
  • 1行の文字数は78文字を超えないことが望ましい (SHOULD)
  • エンコードされた文字列の場合は1行あたり76文字以下にすること
  • これらの制限を超える場合はCRLFで区切って続ける

これはメール本文に限らず、件名にも適用されますし、もっとちゃんと言うならMTA(Mail Transfer Agent)との通信全般に適用されるルールです。

PHP等で件名と本文だけ指定してメール送信していると忘れがちですが、実際には下記のような形式でメールは送信されています。

このメッセージの全ての行において、必ず998文字以下であることが求められ、出来れば78文字以内にすることが推奨されています。

もし78文字を超える場合はCRLFを入れて続きを次の行に書くようにします。

…が、それは生データの話であって、base64等でエンコードされた文字列の場合は1行あたり76文字以内に収める必要があります。そして文字コードにUTF-8を使い、日本語を扱う以上、エンコードは避けて通れません。

PHPではmb_encode_mimeheaderという関数が自動的に一定の文字数ごとに改行(CRLF)を入れてくれるのですが、逆にこのせいで文字化けするケースもあるため、ここでも注意が必要。詳細は後述します。

文字コードとエンコードについて

  • メールの件名/本文共に日本語や絵文字が使われることも当たり前となっているため文字コードはUTF-8が望ましいと思われる
  • エンコード(Content-Transfer-Encoding)は7bitしか通さないMTAが存在することや、日本語中心であることを考えるとBase64が望ましい
    (quoted-printableでも構わないが日本語が多く含まれれば含まれるほどBase64よりもデータ量が膨れ上がる)

文字コードとエンコードを一緒くたに考えてしまっている人がいますが、メールでいうエンコードは一般的に Content-Transfer-Encodingのこと。

つまり、文字コードはUTF-8、ShiftJIS、ISO-2022-JP、EUC-JPであり、Content-Transfer-Encodingは7bit、8bit、base64、quoted-printableのいずれかから選びます。

ややこしい? なら文字コードはUTF-8、Content-Transfer-Encodingはbase64にする、と覚えておけば良いです。

文字コードがUTF-8でContent-Transfer-Encodingを8bitにしておけばメールの件名も本文もUTF-8そのまんまなのでデバッグはしやすいのですが、不特定多数に送る可能性があるのならやはり避けたほうが良いです。7bitしか通さないMTAが存在するからです。

えー、いまどきぃ?と思われるかもですが、不特定多数向けのWebサイトを運営していると実感します。日本人でも匿名性のためかプロキシーを通して等、わざわざ海外のフリーメールを使っている人がおり、8bitを通さないMTAを経由してメール受信している人が実在します。2022年現在でも。

となると、残りは7bit、base64、quoted-printableの3つになるわけですが、最初の7bitはASCIIコードしか使えず、日本語で7bitで送信するとなるとISO-2022-JP限定になるので除外します。UTF-8に比べると使えない漢字が多いですし、UTF-8で定義された絵文字なども全滅だからです。

base64とquoted-printableのどちらを使うかは意見がわかれるところですが、個人的にはサイズの都合上、base64と思っています。

base64とquoted-printableのサイズの差

例えば「いつもお世話になっております。」というUTF-8の文字があったとしましょう。

これは全角15文字ですが、データ量(バイト数)でいうと45バイトです。なぜなら日本語をUTF-8にすると1文字あたり3バイト使うからです。
(絵文字等、4バイトの文字も存在する)

ちなみに英数字の場合は1文字=1バイトです。全世界の文字を1文字2バイトにしようぜって話でUnicodeが出来たのに欧米人が…………いやその話は脱線するからやめましょう。

では、先ほどの文字をPHPでbase64とquoted-printableにエンコードして結果を見てみましょう。

echo base64_encode("いつもお世話になっております。");
echo quoted_printable_encode("いつもお世話になっております。");

■Base64へ変換した結果 (60バイト)

44GE44Gk44KC44GK5LiW6Kmx44Gr44Gq44Gj44Gm44GK44KK44G+44GZ44CC

■quoted-printableへ変換した結果 (138バイト)

=E3=81=84=E3=81=A4=E3=82=82=E3=81=8A=E4=B8=96=E8=A9=B1=E3=81=AB=E3=81=AA= =E3=81=A3=E3=81=A6=E3=81=8A=E3=82=8A=E3=81=BE=E3=81=99=E3=80=82

quoted-printableはASCII文字はそのままですが、それ以外の文字は「=」を付けてコードで表現するため、1文字あたり9バイトくらい使います。

英数字しか送らない場合や、文字コードがISO-2022-JPなら、(7bit文字なので)Content-Transfer-Encodingをquoted-printableにすることで最も少ないデータ量で済むため、昔はこれが主流でした。

しかし昨今はスマホでもUTF-8に対応していますし、絵文字も普通に使うため、メールの文字コードもUTF-8が主流。そうなるとデータ量が3倍くらいに肥大化するquoted-printableよりも、1.5倍程度で済むbase64のほうが良いでしょう。

PHPでHTMLメールを送信するサンプル

最近はメルマガではもちろんのこと、SNSの通知メールですらHTMLメールが当たり前となっているので、HTMLメールを送信するサンプルにしました。

PHPサンプル

$mail_to = '[送信先メールアドレス]';
$mail_from_addr = '[送信元メールアドレス]';
$mail_from_name = '[差出人名]';
$mail_return = '[リターンメールアドレス]';
$mail_subject = '改行を適切に処理しないと20文字以上の件名で文字化けが発生するためわざと長くしてみた😊';
$mail_body_text = 'メールソフトがHTMLメールに対応していない場合、このメッセージが表示されます。';
$mail_body = '<!DOCTYPE HTML>
<html lang="ja">
<head>
<meta charset="UTF-8">
<style>h1 { font-size:16pt; }</style>
</head>
<h1>メールの本文だよー</h1>

<h2>メールの長さ制限について</h2>
<ul>
<li>1行の文字数は998文字を超えてはならない (MUST)</li>
<li>1行の文字数は78文字を超えないことが望ましい (SHOULD)</li>
<li>エンコードされた文字列の場合は1行あたり76文字以下にすること</li>
</ul>
</body>
</html>';

mb_language('Japanese');
mb_internal_encoding('utf-8');

$crlf = "\r\n";

//件名と差出人名をMIME形式へ変換
$subject = mb_encode_mimeheader($mail_subject, 'utf-8', 'B', $crlf);
$from_name = mb_encode_mimeheader($mail_from_name, 'utf-8', 'B', $crlf);

$boundary = "__BOUNDARY__".uniqid(rand(), 1). "__";
//ヘッダー
$header = "MIME-Version: 1.0".$crlf;
$header .= 'Content-Type: multipart/alternative; boundary="'.$boundary.'"'.$crlf;
$header .= "From:{$from_name}<{$mail_from_addr}>".$crlf;
//メール本文
$message = '';
$message .= '--'.$boundary.$crlf;
$message .= "Content-Type: text/plain; charset=utf-8".$crlf;
$message .= "Content-Transfer-Encoding: base64".$crlf;
$message .= $crlf;
$message .= chunk_split(base64_encode($mail_body_text), 76, $crlf);

$message .= '--'.$boundary.$crlf;
$message .= "Content-Type: text/html; charset=utf-8".$crlf;
$message .= "Content-Transfer-Encoding: base64".$crlf;
$message .= $crlf;
$message .= chunk_split(base64_encode($mail_body), 76, $crlf);
$message .= '--'.$boundary.$crlf;

//メール送信
mail($mail_to, $subject, $message, $header, '-f'.$mail_return);

解説

前半部分は送り先メールアドレスなどの設定だけなので、実質のコード部分は mb_language('Japanese'); 以降です。

改行コードは変数にしておくべき

まず一番ハマりそうなポイントが改行コード。

RFCの仕様では行末にはCRLFを書くというルールになっているのですが、qmailというMTAはあろうことかメッセージ中のLFをすべてCRLFへ変換するという処理をしてくれやがります。

いつもお世話になっております。[CRLF]
と行末に[CRLF]を付けたつもりが、qmail側では勝手に、
いつもお世話になっております。[CRCRLF]

というメッセージに変換されてしまうのです。メールクライアントによってはCRLFは改行、CRも改行、と判断するため、すべてのメッセージが1行置きに表示される、という状況になります。そんなメール、受け取ったことありませんか?

それだけならまだしも、ヘッダー部分が無駄に改行されてしまうと別の意味になってしまいます。具体的にはヘッダーを送信した後、「空行」を1行送信してから本文を送信する、というルールがあるため、ヘッダー内で無駄に改行されると本文がはじまったと判断され、メール本文にヘッダーが流れ込むことになります。そんなメールも受け取った経験ありませんか? …ぼくはあります。

sendmailならCRLFで問題ないのですが、このqmailを使っているレンタルサーバーはいまだに多いため、お使いのサーバーによってはわざと行末をLFにする必要があります。そのため、このサンプルでは $crlf = "\r\n"; としているのですね。qmailを使っているとわかっている場合には、ここを $crlf = "\n"; に変更します。

mb_encode_mimeheaderにも改行コードを指定

mb_encode_mimeheader関数には文字コードがUTF-8で、エンコードはbase64だよ、という指定のほかに改行コードの指定もできます。

$mail_subject = '改行を適切に処理しないと20文字以上の件名で文字化けが発生するためわざと長くしてみた😊';
$subject = mb_encode_mimeheader($mail_subject, 'utf-8', 'B', $crlf);

サンプルではわざと長い件名にして、絵文字まで入れていますが、これがエンコードされると下のような文字列になります。

Subject: =?UTF-8?B?5pS56KGM44KS6YGp5YiH44Gr5Yem55CG44GX44Gq44GE44GoMjDmloflrZc=?=
 =?UTF-8?B?5Lul5LiK44Gu5Lu25ZCN44Gn5paH5a2X5YyW44GR44GM55m655Sf44GZ44KL?=
 =?UTF-8?B?44Gf44KB44KP44GW44Go6ZW344GP44GX44Gm44G/44Gf8J+Yig==?=

PHPのマニュアルによると74文字ごとに改行を入れる仕様のようです。RFC2045の仕様、76文字から記号分の余裕を持たせているのかな?

Gmail等で受信したときに件名が文字化けする場合は、メニューからメッセージのダウンロードを選べば拡張子.emlというファイル(中身はテキストファイル)が取得できるので、テキストエディターで中身を確認してみると良いでしょう。

長い件名なのに78文字でCRLFが入っていなかったり、CRLFがLFだけになってしまっていたり、文字化けの原因を特定することができます。ちなみにCRLFの代わりにLFでも正常に表示されるメールクライアントも多数ありますし、事実Gmailでは問題なく表示できちゃったりしますが、RFC違反なのでちゃんと行末がCRLFになるようにして送信しましょう。

BOUNDARY(=境界線)は前後に付ける

HTMLメールはマルチパートメールとして送信するのが一般的です。

マルチパートメールとは、異なる形式のメッセージを複数含んだメールのことですね。テキストメール+添付メール(バイナリ)だったり、今回の例だとテキストメール+HTMLメールです。

HTMLメールが受信できないメールクライアントなんてないやろ、と思われるかもですが、古いメールクライアントはもちろんのこと、一部のレンタルサーバーに標準付属のWebメールシステム(具体例を出すとさくらインターネットとか)ではHTMLメールに対応していないケースもあるので、一応マルチパート形式にしておくほうが無難。

HTMLメールの表示はできずとも、テキストメール部分を表示しつつ、添付ファイルとしてHTMLファイルを開けるようにしているメールクライアントなんてのもありますから。

さて、BOUNDARYはその名のとおり、境界線の意味で、ここからがテキストメールだよー、そしてここからがHTMLメールだよー、というような区切りに使います。その区切り記号自体、自分で設定できるようになっており、ざっくりやるならテキトーに "__BOUNDARY__" とでも指定しておけば良いし、そんなメルマガもちょくちょく見かけますが、まぁせっかくなので真面目に乱数を指定しています。

$boundary = "__BOUNDARY__".uniqid(rand(), 1). "__";

こんなふうにuniqidとrand関数を使ってランダムな文字列を設定しておけば、メール本文とかぶっておかしくなることもないだろうってことですね。

$header .= 'Content-Type: multipart/alternative; boundary="'.$boundary.'"'.$crlf;

そしてContent-Type:multipart/alternative; で、このメールはマルチパートですよー、ということと、boundary=で、境界線の記号はコレだよー、と先ほどのランダム文字列を指定しています。

続いて、メッセージ部分は下記のとおり、ハイフン2つから開始する境界線で分け、それぞれのContent-TypeやContent-Transfer-Encodingを指定する仕組み。

--__BOUNDARY__205931421862c3f3f5c8c8a2.88945220__
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64

<<テキストメールの本文>>
--__BOUNDARY__205931421862c3f3f5c8c8a2.88945220__
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: base64

<<HTMLメールの本文>>
--__BOUNDARY__205931421862c3f3f5c8c8a2.88945220__
ここでも大事なのが改行です。CRLFで送信するのはもちろんのこと、Content-Type/Content-Transfer-Encoding等の指定の後、本文が始まる前に空行が必ず必要です。
--__BOUNDARY__205931421862c3f3f5c8c8a2.88945220__
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64
<<テキストメールの本文>>
--__BOUNDARY__205931421862c3f3f5c8c8a2.88945220__

こんなふうにContent-Transfer-Encoding: base64と書いた直後にすぐ本文を書いちゃダメってこと。これ、案外ハマります。というのも、Gmail等の一部のメールクライアントではこのくらいのエラーは吸収して正常に表示してくれちゃうんですよね。

ところが、Yahooメールでは文字化けどころか、本文が一切表示されない状態になります。空白。

更にややこしいことに同じGmailでも、POP受信で外部メールを取り込むように設定したGmailだと、この書き方で文字化けが発生。

わけわかんないでしょ?

ともあれ、仕様を守っていないほうが悪いので、ちゃんと本文の前に空行を入れるようにしましょう。

本文中の1行の文字数制限にはchunk_splitを使う

↓この部分。

$message .= chunk_split(base64_encode($mail_body), 76, $crlf);

base64_encodeでエンコードした後、chunk_splitで指定文字数(ここでは76文字)ごとにCRLFで改行を入れています。

これは最初に書いた「1行の文字数は78文字を超えないことが望ましい」というRFCの仕様を守るためですね。

…………………78じゃなくて76なのは何でなんだ。chunk_split関数のデフォルト値が76だからそう書いたのですが、chunk_splitはRFC2045に準拠しているとのこと。たしかに76文字とある…。

RFC5322では1行あたりの文字数が78文字を超えないことが望ましい、RFC2045ではエンコードされた文字が76文字を超えてはいけない。

頭がこんがらがりそうだけど、ともかく、そういう仕様なのだそうです。

迷惑メール判定されたくないならReturn-Pathも指定しておく

以前、「PHPでメール送信する際に迷惑メールと判断されないために気をつけること」でも書きましたが、mail関数のadditional_paramsに -f オプションを指定することで、メールヘッダーのReturn-Pathが設定できます。

ここに差出人と同じメールアドレスが入っていないと迷惑メール判定されるケースがあったので、できれば設定しておいたほうが良いでしょう。

ちなみに大量に発信するメルマガ発行システムなどではあえてReturn-Pathに別のメールアドレスを指定しておき、定期的にそのメアドをチェックして不達エラーが届いていないか確認して、一定回数以上のエラーがあったらメルマガから登録解除する、なんて仕組みを採用しているところもあります。

人間がメールクライアントで[返信]ボタンを押したときの送り先(返信先)はFromで指定されたメアドになりますが、postmasterなどのシステムが不達メールなどの通知を行う先はReturn-Pathに指定されたメアドになるんです。

これを利用してアドレス収集を行うスパム業者がいるため、FromとReturn-Pathが同じドメインでない場合には迷惑メールの可能性が高い、と判断されるのかも知れません。

まとめ

HTMLメールを送信するサンプルコードだけ載せて終わりにするつもりが、これだと1週間後の自分は内容忘れてそうだなぁ~と感じたので、補足説明を付けているうちにえらい長くなってしまった…。

まぁー、とにかく改行コードが鬼門です。

文字コードはUTF-8なのはもうデファクトスタンダードだし、するとエンコードは自然とbase64が効率的になります。

なら、あとは改行コードの問題のみ。

qmailが勝手にLFをCRLFに勝手に変換してしまうのは有名な話なのですぐに気が付きましたが、マルチパートメールにした際、本文手前に空行が必要という点を見逃していて丸1日悩みました。

どうせしばらくしたら忘れて同じことを繰り返しそうな気がするので、願わくば将来の自分がこの記事にたどり着きますように。

関連記事

コメント

  • 大変詳しい説明ありがとうございました。
    ですが、サンプルコードを試したところ、本文が空の状態で届いてしまいます。
    iPhoneとMacの標準メーラーで、そのような状況です。
    ちなみに件名は文字化けせず、絵文字もちゃんと表示されております。
    [返信]
    • iPhoneもMacも持っていないのでエスパーするしかないのですが、最後のバウンダリーの後に改行をもうひとつ付けてみて頂けますか? (空行がないと本文の終わりと判定しないのかも知れません)

      例)
      $message .= '--'.$boundary.$crlf.$crlf;

      自分では確認できませんが、これと同様のコードでPHPからHTMLメールを送信し、知り合いのiPhoneユーザーからは正常に表示できていると聞いております。
      [返信]
  • 返信ありがとうございます。
    空行を追加してみましたが、やはり「このメールには本文がありません」と出てしまいました。
    PHPを始めとする環境のせいでしょうか。

    実は現在使用しているHTMLメールを送信するスクリプトでたまに文字化けが発生し、原因を探っているところでこちらのページに辿り着いた次第です。
    いろいろとルールがあることを知れただけでも大収穫でした。
    ありがとうございました。
    [返信]
    • iPhoneやMacで出来るのかわかりませんが、受信側でメールのソース(生データ)表示をして、ヘッダー、改行コード、1行あたりの文字数、それぞれが適切かお調べになるのが良いかと思います。

      自分の場合はAndroidのGmailではソース表示ができなかったので、Web版のGmail/Liveメール/Yahooメール等々、複数のWebメールで受信しソースを確認しました。

      それで思い出しましたが、この記事を書いた当時はYahooメールだけ本文が空白になる現象に悩まされていました。たしかqmailが改行コードを勝手にCRCRLFへ変換してしまい、ヘッダーの終わりがわからなくなっていたのが原因だったような…。半年以上前なので記憶があいまいですが、そういえば件名と本文はLFで、ヘッダーはCRLFという変則的な対応をした気も…。

      当時書いていたコードを引っ張り出してきました。改行コードの処理がサンプルとちょっと違いますね…。

      //件名と差出人名をMIME形式(base64)へ変換
      $subject = mb_encode_mimeheader($mail_subject, 'utf-8', 'B', "\n");
      $from_name = mb_encode_mimeheader($mail_from_name, 'utf-8', 'B', "\n");

      //ヘッダー
      $boundary = "__BOUNDARY__".uniqid(rand(), 1). "__";
      $header = "MIME-Version: 1.0\r\n";
      $header .= 'Content-Type: multipart/alternative; boundary="'.$boundary.'"'."\r\n";
      $header .= "From:{$from_name}<{$mail_from_addr}>\r\n";

      //メール本文
      $crlf = "\n";
      $message = '';
      $message .= '--'.$boundary.$crlf;
      $message .= "Content-Type: text/plain; charset=\"utf-8\"".$crlf;
      $message .= "Content-Transfer-Encoding: base64".$crlf.$crlf;
      $message .= base64_encode("恐れ入りますがHTMLメール対応のメールソフトでご覧ください").$crlf;
      $message .= '--'.$boundary.$crlf;
      $message .= "Content-Type: text/html; charset=\"utf-8\"".$crlf;
      $message .= "Content-Transfer-Encoding: base64".$crlf.$crlf;
      $message .= chunk_split(base64_encode($body), 75, $crlf).$crlf;
      $message .= '--'.$boundary."--".$crlf.$crlf;
      //メール送信
      mail($mail_to, $subject, $message, $header, '-f'.$mail_return);


      このソースを見る限り、qmailは件名と本文中のLFをCRLFへ強制変換するのでそれに対応し、ヘッダー部分だけは普通にCRLFを出力しているようです。
      記事のサンプルコードでも複数のMTA&複数のWebメールで動作確認した上で投稿した気がしますが、半年も経つと自信がなくなってきます。

      いずれにせよ、
      ・件名を20文字以下の英数字のみにして本文が表示されないか試してみる
      ・Yahooメールで受信するとどうなるか確認してみる(ソースも表示可能)

      このあたりを試されると良いかも知れません。
      [返信]
  • こんにちは。
    ほんともう度々ありがとうございます!
    改行コードを試してみましたが、やはりダメでした。

    で、自分の送信スクリプトと見比べて、51行目を以下のように変更してみたところ、無事本文が表示されるようになりました!
    $message .= '--'.$boundary.'--'.$crlf;

    私が思うに、2番目の'--'が本文の終わりを示し、それがなかったからメーラーが本文を取得できずにいたのではないかと。
    賢いメーラーは気を利かせてくれるのだと思いますが、少なくともMacとiPhoneの標準メーラーでは必須(バージョンにもよる?)・・・ということでしょうか。

    ともあれ、今回は大変勉強になりました。
    本当にありがとうございました!
    [返信]
    • こちらこそ結果をお知らせ頂けて勉強になりました!
      そういえば、たしかにこの部分を付けるか付けないかで挙動が変わるWebメールサービスもあったことを思い出しました。

      $message .= '--'.$boundary."--".$crlf.$crlf;


      記事中のサンプルも修正しておくことにします。
      [返信]

新しいコメントを投稿する

[新規投稿]
 
TOP