(第〇弾なのかわからなくなってしまったが……)久々のおさらいシリーズ。
最近、仕事で単発のメールフォームを作ったのだが、メール送信周りの処理をスクラッチしたところ、受信するメーラーによって文字化けしたり、迷惑メール化しちゃったりと結構色々大変だったので、そのノウハウをまとめておこうかと思った次第。
まぁ、基本的に送信時のメールヘッダを「これでもか!」ってぐらい念入りに設定してあげるのがトラブル起こさない秘訣である。横着して簡素なメールヘッダにすると、たいていどこかでトラブルが起きるんだよね(苦笑)
PHPで日本語メールを送るならmb_send_mail()
関数を使う
日本語などのマルチバイト文字列が件名や本文に含まれるメールを送信する場合は、mb_send_mail()
関数を使うのが安心である。この関数はmail()
のラッパーで、実体であるmail()
関数がメール送信処理を行う前に件名や本文を自動でエンコードしてくれる。いわゆるマルチバイト文字列フィルター付きのメール送信関数と云える。
この関数を実行する前に、mb_language()
関数で使用言語を指定し、mb_internal_encoding()
で内部エンコードを指定しておけば、その指定に沿った言語セットとエンコード方式でメールのマルチバイト文字を自動変換してくれるので、件名や本文などを個別にエンコード処理するといった手間や処理ミスから解放されるので、心穏やかにメール送信処理を組めるようになるのだ。
あと、多言語対応システムでメールを送信する場合に、言語や文字コードを変数化して切り替えることが容易なので、スッキリした汎用的なコードが書けるというメリットもある。
mb_language( 'Japanese' );
mb_internal_encoding( 'UTF-8' );
mb_send_mail( $send_to, $subject, $body, $headers );
こんな風に使うのが一般的だ。
メールヘッダの一覧
必要最小限のメールヘッダ定義だけでメール送信を行うと、受信するメーラーによって文字化けやら迷惑メールフィルタに引っかかったりと様々なケースが発生する。開発していると、動作検証するメーラーはPCのGmailとスマホのメーラーぐらいまでと、そこまで数多くのメーラーでの受信確認までは手が回らない。そのため、それら限定されたメーラー群では不具合が出なかったのでリリースしてみたものの、実際運用したら、やれこのメーラーだと文字化けした、このメーラーだと迷惑メールに分類されてしまっただのというケースがしばしば発生するのだ。
そういう出戻りを極力避けるためにも、メールヘッダはきちんと念入りに設定しておくのが最善だ。たいていメール送信における問題は、メールヘッダの設定や調整でほぼトラブルシュートができるので、先んじてきちんと設定しておくのが重要になってくる。
まずは、メールヘッダの種類と設定値をここで抑えておくことにする。コーディングの基本は「理解して」使うことが何よりも重要なのだ。
下記一覧の「必要度」が「〇」なものがメールを送る際には設定しておくべきヘッダだ。「△」はなるべく設定しておいた方が良い必要度で、「-」は宛先のメール種別や転送を伴うか等必要に応じて使うヘッダとなる。まぁ、最低限「〇」と「△」のメールヘッダはすべて設定するようにしておけば、そうそうトラブルは起きないはずだ。
ヘッダ名 | 設定値の内容 | 補足 | 必要度 |
---|---|---|---|
MIME-Version |
英数字以外の文字や画像を取り扱える電子メールであることを示すMIMEのバージョン番号 | 現在は1.0のみ規定されているので固定値で1.0 を指定する |
△ |
Content-Transfer-Encoding |
メール転送中の符号化方式を指定する。初期値の7bit の他、8bit 、binary 、quoted-printable 、base64 が指定可能 |
日本語を取り扱う場合は初期値の7bit で良い。base64 等にすると送信メールのデータ量が肥大化してしまうデメリットがあるので注意 |
△ |
Content-Type |
メールのメッセージ中のデータ形式。設定値の仕様は{type}/{subtype}; {parameter},... である |
テキストメールならtext/plain; 、HTMLメールならtext/html; とし、パラメータとして文字コード指定(charset=UTF-8 等)を付与しておくことを推奨する(※後述する) |
〇 |
Return-Path |
メールが届かなかった場合に、そのメールが送り返される返信先のメールアドレス。Reply-Toと異なり、メールサーバが付加するヘッダ値となる。別名「エンベローブ・フロム・アドレス」 | 指定しても、通常メールサーバによって上書きされてしまうが、強制的に指定値を押し通すことも可能(※後述する) | 〇 |
From |
(差出人の名前もしくは組織名と)送信元メールアドレス | 複数指定可能。複数指定する場合はカンマ, で区切る。"{差出人名} <{送信元メールアドレス}> "もしくは{送信元メールアドレス} のみのいずれかで指定する |
〇 |
Sender |
実際の(差出人の名前もしくは組織名と)送信元メールアドレス | 複数指定不可。"{差出人名} <{送信元メールアドレス}> "もしくは{送信元メールアドレス} のみのいずれかで指定する |
〇 |
To |
(受信者の名前と)宛先のメールアドレス | 複数指定可能。複数指定する場合はカンマ, で区切る。FromやSenderの書式が利用可能 |
〇 |
Cc |
カーボンコピー先の(受信者の名前と)宛先メールアドレス | 複数指定可能。複数指定する場合はカンマ, で区切る。FromやSenderの書式が利用可能 |
- |
Bcc |
ブラインドコピー先の(受信者の名前と)宛先メールアドレス | 複数指定可能。複数指定する場合はカンマ, で区切る。FromやSenderの書式が利用可能 |
- |
Resent-From |
メール転送時の転送元メールアドレス | 複数指定可能。仕様はFromやToと同等 | - |
Resent-Sender |
メール転送時の実際の転送元メールアドレス | 複数指定不可 | - |
Resent-To |
メール転送時の転送先のメールアドレス | 複数指定可能。仕様はFromやToと同等 | - |
Resent-cc |
メール転送時のカーボンコピー先のメールアドレス | 複数指定可能。仕様はFromやToと同等 | - |
Resent-bcc |
メール転送時のブラインドコピー先のメールアドレス | 複数指定可能。仕様はFromやToと同等 | - |
Subject |
メールの件名 |
mail() では別途引数化されているのでヘッダに指定する必要はない |
× |
Comments |
任意のコメント | - | |
Keywords |
メール本文において注目してほしいキーワード等 | メーラーによっては指定されたキーワードをハイライトしてくれたりするらしいが、ほとんど実装されていない | × |
Reply-To |
返信先のメールアドレス | 一般的に、未指定時はFromの値が返信先として使用される | 〇 |
In-Reply-To |
返信時にどのメールへの返信かを示す値。Message-IDの値を指定する | 複数指定不可 | - |
References |
返信などで関係している他のメッセージの一覧。Message-IDの値を指定する | 複数指定可能。多くのメーラーでこの値を元にスレッド表示を行っている | - |
Message-ID |
個別のメールを特定するためのユニークID。{ユニークID}@{ドメイン名(FQDN)} という形式で指定する。 |
このIDは全世界で唯一なものにならなければならないと規定されている | - |
Date |
メールの作成(=送信)日時 | メールサーバ側で付与するので設定不要 | × |
Recieved |
メールを転送したメールサーバ情報を示す文字列 | メールサーバが追加する文字列のため設定不要 | × |
Encrypted |
暗号化用の設定 | 実際には、必要に応じてメールサーバ側でメールの暗号化を行うので設定は不要 | × |
Mail-Followup-To |
主にメーリングリストで利用されるメーリングリストの宛先 | 複数指定可能 | - |
Mail-Reply-To |
主にメーリングリストで利用される送信元のメールアドレス | 複数指定不可 | - |
Organization |
送信者が所属する組織名 | △ | |
Precedence |
メールサーバの配信優先度。list >junk >bulk の順に優先される |
メールサーバによってこの値を使うかどうかは異なるので、必ずしも設定した優先度通りに配信されるわけではない | - |
Return-Receipt-To |
配送確認の返信先メールアドレス。メールサーバのメールボックスにメールが届いた際に、メールサーバ側がこの返信先へ返信を行う | 開封通知とは異なるので注意 | - |
Errors-To |
エラー時の返信先メールアドレス | 主にメーリングリストなどで利用される | - |
Disposition-Notification-To |
開封通知の返信先メールアドレス | - | |
X-***** |
X- で始まるメールヘッダは送信元が任意に設定可能な拡張ヘッダとなる |
このヘッダ値が解釈されるかは受信するメーラー側に依存するので、必須なヘッダではない | × |
X-Sender |
送信元のメールアドレスを示す | メーラーごとの拡張仕様のため、使用されるかは受信したメーラー次第となる | △ |
X-Mailer |
一般的には、送信元のメールソフト(メーラー・アプリケーション)の種類を示す | メーラーごとの拡張仕様のため、使用されるかは受信したメーラー次第となる | △ |
X-UIDL |
POP利用時用のユニークID | POPサーバが付加する値なので設定不要 | × |
X-Priority |
メールの重要度。通常は 3 で、1(高)~5(低)で指定できる | メーラーごとの拡張仕様のため、使用されるかは受信したメーラー次第となる | △ |
X-Msmail-Priority |
マイクロソフト製メーラー(Outlook等)用の重要度。仕様はX-Priorityと同じ | メーラーごとの拡張仕様のため、使用されるかは受信したメーラー次第となる | - |
いやぁ、今回メールヘッダの設定値を調べてみて、色々と勉強になった。今までそこまで意識もせずに漠然と設定してた自分に喝を入れたいものだ。
メールヘッダは省略しないで設定しよう
ここからは実践編だ。前項のリストを元に、メールヘッダを省略せずに設定していこう。基本的にメールヘッダは改行コード区切りのプレーンな文字列として取り扱われるので、最終的に文字列としてmb_send_mail()
に渡してあげる必要があるのだが、設定値が多いのでそのまま文字列として定義していくとコードの見通しが悪くなる。そこで、連想配列として定義して、最後に文字列展開するのがベストだと思う。
なお、PHP 7.2以降では配列型の引数を許可するようになったので、下記の例だと、そのまま$headers
を第4引数に渡してしまってOKのようだ。
$headers = [
'MIME-Version' => '1.0',
'Content-Transfer-Encoding' => '7bit',
'Content-Type' => 'text/plain; charset=UTF-8',
'Return-Path' => 'from@example.com',
'From' => 'SenderName <from@example.com>',
'Sender' => 'SenderName <from@example.com>',
'Reply-To' => 'from@example.com',
'Organization' => 'OrganizationName',
'X-Sender' => 'from@example.com',
'X-Mailer' => 'Postfix/2.10.1',
'X-Priority' => '3',
];
array_walk( $headers, function( $_val, $_key ) use ( &$header_str ) {
$header_str .= sprintf( "%s: %s \r\n", trim( $_key ), trim( $_val ) );
} );
mb_send_mail( $send_to, $subject, $body, $header_str );
mb_send_mail()
関数に渡すメールヘッダ文字列の変数$header_str
はarray_walk()
関数のクロージャに参照渡しされ、初回のクロージャ起動時に初期化されるのでグローバルスコープ下での初期化は不要だ。
蛇足だが、連想配列をキー・バリュー形式の文字列に展開する時は、このクロージャを使うやり方がお手軽なので、覚えておくと重宝する。
Content-Type
ヘッダには文字コードパラメータを含めること
Content-Type
ヘッダがContent-Type: text/plain;
のようなタイプ指定だけだと、Thunderbird等のメーラーで本文が文字化けする場合がある。これは、メールヘッダの指定時に、mb_language( 'Japanese' )
とmb_internal_encoding( 'UTF-8' )
で言語や文字コードを指定していても回避できない。おそらく、メーラー側でContent-Type
ヘッダ値に文字コードがない場合は、ユーザが指定しているメーラーのエンコーディング指定を優先してしまうからと推測される。
なので、対策としてはContent-Type
ヘッダには優先的にエンコードする文字コードを制御するために文字コードパラメータを含めておくことである。
Content-Type: text/plain; charset=UTF-8
日本語などのマルチバイト文字列を含むテキスト形式のメールを送信する場合であれば、上記のようにメールヘッダを指定しておくのが一番安全だ。
メールヘッダにマルチバイト文字列を含む場合はmb_encode_mimeheader()
を使う
mb_send_mail()
関数がマルチバイト文字列を自動エンコードするのは「件名」と「本文」だけで、メールヘッダに含まれるマルチバイト文字列はエンコードされない。なので、メールヘッダに日本語を含むような場合は、それぞれ個別にエンコードしてあげないと文字化けが起こる場合がある(Outlookやスマートフォンのメーラー等で起きやすい)。そのため、mb_encode_mimeheader()
関数を利用して、メールヘッダを個別にエンコードしていこう。
$headers = [
'From' => mb_encode_mimeheader( '送信者の名前' ) .' <'. $from_email_address .'>',
'Sender' => mb_encode_mimeheader( '送信者の名前' ) .' <'. $from_email_address .'>',
'Organization' => mb_encode_mimeheader( '送信者の組織名' ),
];
通常、メールヘッダ内でマルチバイト文字列を含む可能性があるのは上記のように「From」「Sender」「Organization」ぐらいなので、それらのメールヘッダを設定する場合には注意しておこう。一応、「To」や「Cc」「Bcc」等にも宛先の受信者名としてマルチバイト文字列を含むことができるが、システムから送信するメールについて、そこまでフランクな仕様を持ち込まなくても良いのではないかと思うので、今回はあえて割愛している。
Return-Path
ヘッダに指定した値を強制的に適用する
Return-Pathは別名「エンベローブ・フロム・アドレス」と云われ、メールサーバ(SMTPサーバ)側で指定されている値がメール送信時に割り振られる。そのため、PHP側でメールヘッダの値を設定しても通常は無視されてしまうのだ。さらに、メールサーバ側ではメール・アプリケーション(Postfixやqmail等)で最適なメールアドレスをReturn-Pathアドレスとして設定されていないと、初期値が使用されてしまう。例えばPostfixでは、postfix/canonicalで初期値が、
root root@localhost.localdomain
──となっているので、受信したメールのReturn-Pathヘッダのアドレスがroot@localhost.localdomain
等の存在しない値になってしまう。このアドレスのドメイン部分が、メールが送信されたホストのDNSに登録されているSPFレコード(TXTレコードの一種)のドメインやIPアドレスと異なると、受信したメーラー側でSPF認証に失敗して「このメールは送信元をなりすましている可能性が高い」と判断して、迷惑メールに振り分けてしまうのだ。SPFレコードの詳細は、下記の記事が詳しくてわかりやすいので参照してみて欲しい。
さて、これを解決するには、まずメールサーバとしてのメール・アプリケーション側でReturn-PathアドレスをSPFレコードに登録されている最適なFQDNに変更するのが最善手である(なお、そもそもDNSにSPFレコードが設定されていない場合は、どうしようもないので、それは必ずやること)。しかし、そこまでするとなるとPHP以外の知見が必要になって来て何気に面倒なので、ここではお手軽にSPFレコードに登録されているFQDNのメールアドレスをReturn-Pathとして強制的に設定してあげる方法を紹介しておく。
$from_address = 'from@example.com';
$add_params = '-f'. $from_address;
mb_send_mail( $send_to, $subject, $body, $header_str, $add_params );
mb_send_mail()
関数では第5引数additional_parameterを使い、MTA(メール・アプリケーション≒sendmail
もしくはmail
コマンド)にコマンドライン引数を渡すことができる。これを使ってReturn-Pathアドレスを指定することで、MTAがメールヘッダに付与するReturn-PathアドレスをPHP側から強制化できるのだ。
上記の例では、-f
オプション(エンベローブ・フロム・アドレス設定オプション)のパラメータとして送信者のメールアドレスfrom@example.com
をしている。これによって、MTAが送信するメールのReturn-Pathがfrom@example.com
に固定化される。
最後に、受信したメールのReturn-PathヘッダやSPFレコード認証を正常通過しているかを確認してみよう。Gmailで受信した場合での確認例になるが、受信したメールの右上の「その他」のアイコンから「メッセージのソースを表示」をクリックすると、メールヘッダが含まれる全ソースが表示される。
Return-Path: <from@example.com>
Received-SPF: pass (google.com: domain of from@example.com designates ***.***.***.*** as permitted sender) client-ip=***.***.***.***;
上記のように、Return-Pathヘッダが指定したメールアドレスになっていることと、Received-SPFが正常に認証されたことを示す「pass」となっていればOkだ。もしSPF認証に失敗していた場合は、SPFレコードに登録されているFQDNとReturn-Pathアドレスのドメインが一致していない可能性がある。
おまけとして、任意のホストがSPFレコードを持っているかどうかをチェックするためのPHPスクリプトを紹介しておく。
<?php
$host_name = $_SERVER['HTTP_HOST'];// チェックしたいホスト名を指定する
$dns_records = dns_get_record( $host_name, DNS_ALL );
$result = '';
if ( ! empty( $dns_records ) ) {
foreach ( $dns_records as $_record ) {
if ( 'TXT' === $_record['type'] && strpos( $_record['txt'], 'v=spf1' ) !== false ) {
$result .= "The \"{$host_name}\" has a SPF record(\"{$_record['txt']}\")." . PHP_EOL;
}
}
}
if ( empty( $result ) ) {
$result .= "The \"{$host_name}\" does not have a SPF record." . PHP_EOL;
}
echo $result;
exit;
変数$host_name
にReturn-Pathとして設定したいホスト名(FQDN)を指定して実行すると、そのホストのDNSがSPFレコードを持っているかを判定して、そのSPFレコードの設定値を表示してくれる。ただし、該当のホストがSPFレコードを持っていたとしても、その設定値でメーラー側のSPF認証を通過できるかまでは検証しないので、その点は留意してほしい。
MTAのTLS通信設定を確認しておく
Gmail等では、メール受信時にTLS通信がされていないメールについては警告が出る。今のところこれによって迷惑メールに分別されることはないようだが、将来的にはわからない。なので、利用しているMTAで外部へのSMTP通信時にTLS通信を使うように設定しておくと安心である。
例えば、Postfixでは外部へのSMTP通信時にTLSが利用可能ならTLS通信を使う設定が、デフォルトでOFFになっているので、有効化しておこう。Postfixでのやり方は下記の記事が参考になるかと。
メール本文をテンプレート化して外部ファイルで管理する
PHP側の処理に応じてメールの本文を変更したい場合などは、メール本文をテンプレート化して外部ファイルとして管理すると、ソース内にメールメッセージをハードコーディングすることもなく見通しの良いコードが書ける。例えば多言語対応したアプリケーションで、日本語環境ユーザと英語圏ユーザとで送信メールの記述言語を切り分けたい時など、このテンプレート方式にしておくとメール送信周りは汎用コード化でき、メールメッセージへの保守はテンプレート側だけに集約できるので、運用管理が抜群にしやすくなる。
では、下記のようなテンプレートをメール本文として読み込んでみよう。
<?= $display_name ?>さん、こんにちは。
あなたのアカウントが新たに作成されました。
下記のURLから、ユーザー名:<?= $user_id ?>とご登録時のパスワードにてサインインしてください。
サインインURL:
<?= $signin_url ?>
〇〇〇〇事務局より
次に、読み込み元での処理だ。
// 変数定義
$display_name = 'テスト・ユーザー';
$user_id = 'test_user1';
$signin_url = 'https://example.com/signin/?uid=' . $user_id;
// テンプレート読み込み
ob_start();
include __DIR__ . '/templates/mail_body.php';
$mail_body = ob_get_contents();
ob_end_clean();
$mail_body = strtr( $mail_body, [ "\r\n" => "\n", "\r" => "\n" ] );// to all LF
ポイントはテキスト形式であるテンプレートをバッファリングによって読み込むことで、変数の埋め込み処理が完了した状態のテキストを、出力を伴うことなくメール送信用の$mail_bodyとして格納できる点だ。コードの見通しも良いし、読み込むテンプレートファイルを条件分岐させることも容易だ。
あと、最後に改行コードをすべてLFに変換しているが、これは必須ではない。稀に、テンプレートファイルで読み込んだ本文中の改行コードが、メール送信時に重複解釈されて空行が増えたり、逆に改行されないというケースが発生した場合の対処用のコードである。詳しくは、PHPのmail()のドキュメントで下記のように注意されている。
メッセージが受信されなかった場合には、LF(\n)のみを使ってみてください。 Unix の MTA の中には、自動的に LF を CRLF に変換してしまう もの (有名なところでは、qmailなど) があります(もし CRLF を利用していた場合、CR が重複してしまいます)。 ただし、これは最後の手段です。というのも、これは RFC 2822に違反しているからです。
本当はテンプレート読み込み型のメール送信時に改行コードに異常が見られた場合にのみ使った方が良いのだろうが、私は面倒なので一律で変換している。
今回のおさらいはここまで。
最後に、このおさらいで紹介した一連の手順をふまえて、mb_send_mail()
のラッパーとして拡張したメール送信処理を私の個人ブログに掲載してあるので、興味がある人は覗いてみてくださいな。