素の PHP で添付付きメールを送信する
この記事は2022/08/03に作成されました。
前書き
皆さんは PHP でメールを送信していますか?
PHP で開発をする時は大抵なんらかのフレームワークに乗っかって開発するので、さして苦労していないかもしれません。
僕も普段は CodeIgniter などのフレームワークで開発をしているので、メールを送信するときはフレームワーク内蔵のメールライブラリを用いて開発をします。
しかし、たまに素の PHP で開発を行うことがあり、その度に改善し続けてきた「お作法」もだいぶ成熟してきました。
今のところメールが文字化けした、という話は聞かないので、強つよエンジニア達のマサカリに怯えつつお作法の共有をしてみたいと思います。
お作法その一、mb_send_mail() ではなく mail() を使用する
日本語圏の案件で素の PHP を使用してメールを送信する場合、無意識に mail() 関数ではなく mb_send_mail() を選択する人も多いのではないでしょうか?
mb_ という頭文字から察するに、日本語のようなマルチバイト文字を使用する際は mb_send_mail() を使用しなければならない、というような気がしてきますよね。
mb_send_mail() と mail() の違いについてなのですが、mb_send_mail() の PHP 公式ドキュメントには
ヘッダと本文は mb_language() の設定に基づき変換、エンコードされます。
https://www.php.net/manual/ja/function.mb-send-mail.php
これは mail() のラッパー関数です。
とあります。内部で mail() 関数を呼んでいるようですね。
そして、なにやら mb_language() で設定されたなにかの影響を受けるようです。
mb_language() の説明には指定毎に設定される「文字コード/エンコーディング」の表が載っていますが、我等が日本語(Japanese/ja)では次のようになっています。
ISO-2022-JP / BASE64
さて、ここで出てきた ISO-2022-JP は日本語メールに使用される定番の文字コードとされてきました。
現在、選択肢として対抗馬を成す UTF-8 という文字コードと比べ、古いメーラーでも文字化けし辛いというメリットがあります。
ただし現代では時代も進み、ほとんどの一般的なユーザーが使用しているメーラーは UTF-8 での表示に対応しています。
体表的なところでは Gmail などは基本的にメールを UTF-8 で扱います。
UTF-8 は ISO-2022-JP と比べ、特殊文字や絵文字が文字化けせずに使用できるのがメリットです。
従って僕は、かなり古めの PC に対してメールを送る用途が想定されない限り、UTF-8 を選択しています。
つまり、mb_send_mail() は設定により自動的に ISO-2022-JP へ変換をかけるので、mail() を使用したほうが都合がいいわけですね。
さらに突っ込んで考えると、先述した mb_send_mail() についてドキュメントには、
ヘッダと本文は mb_language() の設定に基づき変換、エンコードされます。
https://www.php.net/manual/ja/function.mb-send-mail.php
という説明がありますが、mb_language() で設定される言語設定はこの関数以外にも、php.ini などでも設定可能です。
使用前に必ず mb_language() や mb_internal_encoding() を呼べば問題無いですが、サーバーによって挙動が変わる可能性がある mb_send_mail() に変換を任せるよりか、変換含めて自身で制御した上で mail() を使用する方が安全であるとも考えられます。
お作法その二、エンコーディングは BASE64 で統一
文字コードには UTF-8 を採用しますが、エンコーディング側にも選択肢があります。
ここで選択肢に上がるのは BASE64 か Quoted-Printable のどちらかでしょう。
BASE 64 は元データをメールにとって安全に使用できる 64 文字のみで構成した文字列に変換するエンコードです。
最大のメリットは元データとしてバイナリデータを扱うことが可能な点で、メールに添付されるファイルなど、文字以外のデータも問題なく変換できます。
反面、元データより約 1.3 倍データ量が多くなるのがデメリットです。
対して Quoted-Printable は英文などの変換に適したエンコーディングです。
メールにとって危険な文字を安全な文字の組み合わせへ変換する点は BASE64 と一緒ですが、ASCII 文字、つまり英文のほとんどは変換しなくても安全なので、そのまま変換しないで使用します。
なので人間用のテキストに向いている変換方式ですが、バイナリデータの変換には向いていません。
これらを踏まえ、僕は BASE64 を採用しています。
- 元となるメールの文章はほとんと日本語なので、ASCII 文字をそのまま使用する Quoted-Printable の旨味が少ない
- 添付データも使用できるようにするため、バイナリデータの変換が得意な BASE64 が活きる
- どちらかに統一したほうが、PHP コードで共通化しやすい
といった理由から BASE64 としています。
お作法その三、From や Subject はちゃんとエンコードしてあげる
メールデータには本文以外にヘッダー部分が存在し、そのメールがどのようなものなのかの説明・設定などが Key: Value のような形で羅列されています。
普段何気なく使っている From 欄ですが、メールデータ中では、このヘッダー部分で
From: test@example.com
というように設定されています。
さらに、From は以下のように設定することでメールアドレスだけではなく送信者名も指定することができます。
From: John Smith <test@example.com>
この例ではアルファベットのみで構成されているので、このままで問題無く送信可能です。
ただし、日本語名を使用する場合はやはりエンコードが必要です。
From: 山田 太郎 <test@example.com>
といった指定をしたいとします。
このままではメーラーが正しく解析できないため、BASE64 で「山田 太郎」部分をエンコーディングして差し替えます。
ただし、BASE64 で変換した「5bGx55SwIOWkqumDjg==」だけでは、どのようなエンコーディング方式で変換された文章なのか判別がつきません。
なのでおまじないよのうですが、「5bGx55SwIOWkqumDjg==」を「=?UTF-8?B?」と「?=」で挟んであげます。
「=?UTF-8?B?」の「UTF-8」で文字コード、「B」でエンコーディングを表していると分かればおまじない感は薄れるのではないでしょうか。
From: =?UTF-8?B?5bGx55SwIOWkqumDjg==?= <test@example.com>
これによってメーラーが送信者名を正確に判定して「山田 太郎」と表示をしてくれます。
php コードで関数化するならば以下のようになります。
コメントにもあるように、件名の変換にも使用できます。
/**
* 件名、From の名称を文字化けしないようにエンコードする
* @param string $string
* @return string
*/
function encodeHeader(string $string): string
{
return '=?UTF-8?B?'.base64_encode($string).'?=';
}
お作法その四、Content-Type: multipart/mixed;boundary=HOGEHOGEHOGE で添付ファイルに対応する
From や To 以外にも設定しておきたいヘッダーが 3 つあります。
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Type: multipart/mixed;boundary=HOGEHOGEHOGE
MIME-Version: 1.0 はこのメールが素のテキストメールではなく MIME メールだということを宣言しています。
メーラーが対応していれば、これにより通常のテキストメールには不可能なファイル添付などの機能が使用可能になります。
Content-Transfer-Encoding: base64 は変換エンコーディング方式として BASE64 を使用しているという宣言です。
最後の
Content-Type: multipart/mixed;boundary=HOGEHOGEHOGE
はメール本文がいくつかのパートによって分かれており、その区切り文字が「HOGEHOGEHOGE」であることを示しています。この「HOGEHOGEHOGE」は任意に設定可能です。
メールデータには当然、ヘッダー以外に本文部分が存在します。
実はこの一つしかない本文領域を分割して使用することで「ここからここは本文部分」「ここからここは添付ファイル」といった指定が可能なのです。
その領域の線引きは先述の「boundary=この部分」が使用され、メーラーは本文中、この文字列が登場する部分をデータの区切りとして理解します。
実際は以下のような見た目になります。
--HOGEHOGEHOGE
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: base64
6Zuo44OL44Oi44Oe44Kx44K6DQrpoqjjg4vjg6Ljg57jgrHjgroNCumbquODi+ODouWkj+ODjuaa
keOCteODi+ODouODnuOCseODjA0K5LiI5aSr44OK44Kr44Op44OA44Oy44Oi44OBDQrmhb7jg4/j
g4rjgq8NCuaxuuOCt+ODhueei+ODqeOCug0K44Kk44OE44Oi44K344OF44Kr44OL44Ov44Op44OD
44OG44Ow44OrDQrkuIDml6Xjg4vnjoTnsbPlm5vlkIjjg4gNCuWRs+WZjOODiOWwkeOCt+ODjumH
juiPnOODsuOCv+ODmQ0K44Ki44Op44Om44Or44Kz44OI44OyDQrjgrjjg5bjg7Pjg7Ljgqvjg7Pj
grjjg6fjgqbjg4vlhaXjg6zjgrrjg4sNCuODqOOCr+ODn+OCreOCreOCt+ODr+OCq+ODqg0K44K9
44K344OG44Ov44K544Os44K6DQrph47ljp/jg47mnb7jg47mnpfjg47igLso44CM44CM6JSt44CN
44Gu44CM6Zmw44Gu44Gk44GP44KK44CN44Gr5Luj44GI44Gm44CM5Lq644GM44GX44KJ77yP6auf
44Gu44G444KT44CN44CB56ysNOawtOa6ljItODYtNzgp44OODQrlsI/jgrXjg4rokJPjg5bjgq3j
g47lsI/lsYvjg4vjg7Djg4YNCuadseODi+eXheawl+ODjuOCs+ODieODouOCouODrOODkA0K6KGM
44OD44OG55yL55eF44K344OG44Ok44OqDQropb/jg4vjg4Tjgqvjg6zjgr/mr43jgqLjg6zjg5AN
CuihjOODg+ODhuOCveODjueosuODjuacv+ODsu+8u++8g+OAjOacv+ODsuOAjeOBr+ODnuODnu+8
veiyoOODkg0K5Y2X44OL5q2744OL44K144Km44OK5Lq644Ki44Os44OQDQrooYzjg4Pjg4bjgrPj
g4/jgqzjg6njg4rjgq/jg4bjg6LjgqTjg73jg4jjgqTjg5INCuWMl+ODi+OCseODs+OCr+ODruOD
pOOCveOCt+ODp+OCpuOCrOOCouODrOODkA0K44OE44Oe44Op44OK44Kk44Kr44Op44Ok44Oh44Ot
44OI44Kk44OSDQrjg5Ljg4njg6rjg47jg4jjgq3jg4/jg4rjg5/jg4Djg7Ljg4rjgqzjgrcNCuOC
teODoOOCteODjuODiuODhOODj+OCquODreOCquODreOCouODq+OCrQ0K44Of44Oz44OK44OL44OH
44Kv44OO44Oc44O844OI44Oo44OQ44OsDQrjg5vjg6Hjg6njg6zjg6LjgrvjgroNCuOCr+ODi+OD
ouOCteODrOOCug0K44K144Km44Kk44OV44Oi44OO44OLDQrjg6/jgr/jgrfjg4/jg4rjg6rjgr/j
gqQNCg0K5Y2X54Sh54Sh6L666KGM6I+p6JapDQrljZfnhKHkuIrooYzoj6nolqkNCuWNl+eEoeWk
muWuneWmguadpQ0K5Y2X54Sh5aaZ5rOV6JOu6I+v57WMDQrljZfnhKHph4jov6bniZ/lsLzku48N
CuWNl+eEoea1hOihjOiPqeiWqQ0K5Y2X54Sh5a6J56uL6KGM6I+p6Jap
--HOGEHOGEHOGE
Content-Type: plain/text; name="amenimo-makezu.txt"
Content-Disposition: attachment; filename="amenimo-makezu.txt"
Content-Transfer-Encoding: base64
6Zuo44OL44Oi44Oe44Kx44K6DQrpoqjjg4vjg6Ljg57jgrHjgroNCumbquODi+ODouWkj+ODjuaa
keOCteODi+ODouODnuOCseODjA0K5LiI5aSr44OK44Kr44Op44OA44Oy44Oi44OBDQrmhb7jg4/j
g4rjgq8NCuaxuuOCt+ODhueei+ODqeOCug0K44Kk44OE44Oi44K344OF44Kr44OL44Ov44Op44OD
44OG44Ow44OrDQrkuIDml6Xjg4vnjoTnsbPlm5vlkIjjg4gNCuWRs+WZjOODiOWwkeOCt+ODjumH
juiPnOODsuOCv+ODmQ0K44Ki44Op44Om44Or44Kz44OI44OyDQrjgrjjg5bjg7Pjg7Ljgqvjg7Pj
grjjg6fjgqbjg4vlhaXjg6zjgrrjg4sNCuODqOOCr+ODn+OCreOCreOCt+ODr+OCq+ODqg0K44K9
44K344OG44Ov44K544Os44K6DQrph47ljp/jg47mnb7jg47mnpfjg47igLso44CM44CM6JSt44CN
44Gu44CM6Zmw44Gu44Gk44GP44KK44CN44Gr5Luj44GI44Gm44CM5Lq644GM44GX44KJ77yP6auf
44Gu44G444KT44CN44CB56ysNOawtOa6ljItODYtNzgp44OODQrlsI/jgrXjg4rokJPjg5bjgq3j
g47lsI/lsYvjg4vjg7Djg4YNCuadseODi+eXheawl+ODjuOCs+ODieODouOCouODrOODkA0K6KGM
44OD44OG55yL55eF44K344OG44Ok44OqDQropb/jg4vjg4Tjgqvjg6zjgr/mr43jgqLjg6zjg5AN
CuihjOODg+ODhuOCveODjueosuODjuacv+ODsu+8u++8g+OAjOacv+ODsuOAjeOBr+ODnuODnu+8
veiyoOODkg0K5Y2X44OL5q2744OL44K144Km44OK5Lq644Ki44Os44OQDQrooYzjg4Pjg4bjgrPj
g4/jgqzjg6njg4rjgq/jg4bjg6LjgqTjg73jg4jjgqTjg5INCuWMl+ODi+OCseODs+OCr+ODruOD
pOOCveOCt+ODp+OCpuOCrOOCouODrOODkA0K44OE44Oe44Op44OK44Kk44Kr44Op44Ok44Oh44Ot
44OI44Kk44OSDQrjg5Ljg4njg6rjg47jg4jjgq3jg4/jg4rjg5/jg4Djg7Ljg4rjgqzjgrcNCuOC
teODoOOCteODjuODiuODhOODj+OCquODreOCquODreOCouODq+OCrQ0K44Of44Oz44OK44OL44OH
44Kv44OO44Oc44O844OI44Oo44OQ44OsDQrjg5vjg6Hjg6njg6zjg6LjgrvjgroNCuOCr+ODi+OD
ouOCteODrOOCug0K44K144Km44Kk44OV44Oi44OO44OLDQrjg6/jgr/jgrfjg4/jg4rjg6rjgr/j
gqQNCg0K5Y2X54Sh54Sh6L666KGM6I+p6JapDQrljZfnhKHkuIrooYzoj6nolqkNCuWNl+eEoeWk
muWuneWmguadpQ0K5Y2X54Sh5aaZ5rOV6JOu6I+v57WMDQrljZfnhKHph4jov6bniZ/lsLzku48N
CuWNl+eEoea1hOihjOiPqeiWqQ0K5Y2X54Sh5a6J56uL6KGM6I+p6Jap
--HOGEHOGEHOGE--
後半のブロックでは
Content-Type: plain/text; name="amenimo-makezu.txt"
Content-Disposition: attachment; filename="amenimo-makezu.txt"
というヘッダーで、内容が「amenimo-makezu.txt」というテキストファイルだということがお判りでしょうか。(BASE64 部分は適当です)
このように、区切り文字の下にヘッダーが記載され、空行で挟んだ部分が本文として認識されます。
BASE 文字列が綺麗に改行されていますが、これは
chunk_split(base64_encode($mojiretsu));
といったコードで簡単に作成できます。
また注目すべきは、
・区切り文字をの前に「--」を置くと先頭・文中の区切り文字列
・区切り文字をの前後に「--」を置くと終端の区切り文字列
として作用する点です。
これらを踏まえれば自前でメールデータを文字列として作成できます。
添付がない場合でも、ヘッダーはそのままに、添付ブロック部分を減らせば、ただのメールとして送信可能です。
php コードにすると以下のような実装になるかと思われます。
/**
* メール本文を添付ファイルを含めて返す
* @param string $body
* @param array $attachments
* @param string $boundary
* @return string
*/
function buildBodyWithAttachments(string $body, array $attachments, string $boundary)
{
//最終的に返す文字列
$result = '--'.$boundary."\n"
.'Content-Type: text/plain; charset=UTF-8'."\n"
.'Content-Transfer-Encoding: base64'."\n"
."\n"
.chunk_split(base64_encode($body))."\n"
."\n";
//添付ファイルを追加する
foreach ($attachments as $attachment)
{
//ファイルを添付
$result .= '--'.$boundary."\n"
.'Content-Type: '.$attachment['type'].'; name="'.encodeHeader($attachment['name']).'"'."\n"
.'Content-Disposition: attachment; filename="'.encodeHeader($attachment['name']).'"'."\n"
.'Content-Transfer-Encoding: base64'."\n"
."\n"
.chunk_split(base64_encode($attachment['binary']))."\n"
."\n";
}
//最終の区切りを付け足して返す
return $result.'--'.$boundary.'--';
}
まとめ
このような実装をするのにあたって特に参考になったのは以下の二つです。
・実際に送信されてきたメールのソース
・普段使っているフレームワークのソース
世の記事もかなり調べましたが、やはり手元にある成功例・動くと分かっているコードがとても参考になりました。
この記事を読んでいる皆さんが実装する機会があれば、この記事のみならず、これらも参考にしてみてください。
プログラマー/N.Go