PHPで文字化けしないHTMLメールを送信するために必要な知識とサンプルコード
メールを送信するプログラムは何度も書いて、実際に運用もしてきていますが、だいたいは受信する環境がわかっている(例えばGmailで受信するとあらかじめ知っている等)状況でした。
しかし、不特定多数の様々な環境の人へメールを送る必要に迫られ、あらためて文字化けとの戦いが勃発し、あー、自分なんも理解してなかったッスわー、と今更ながら気が付いたので、HTMLメール送信で学んだことを備忘録としてまとめておきます。
メール送信で特に気を付けるべき仕様
基本的にはRFC5322やRFC2045に従えば良いのですが、いちいち読んでられんって人も多いと思うので、特にハマりがちなところを抜粋します。
メールの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日悩みました。
どうせしばらくしたら忘れて同じことを繰り返しそうな気がするので、願わくば将来の自分がこの記事にたどり着きますように。