バーチャルプログラマ・技術者 Advent Calendar 2023 4日目の初投稿記事からいきなり意味の分からない投稿をしていくフルスタックエンジニア系VTuberです。
今回の記事はタイトル通り、PHPでcomposerを使用せずにカスタム可能なメール送信関数を作って送信するという内容となっています。
コードのアイデアは秋雪 こおりが、実装は二人でやったISnow Novelsにて使用される実際のコードを用いて説明します。
この送信関数をまとめてSuccubus Wingと言います。また、ISnow NovelsはLilith Systemと言います。
対象読者
記事の内容が非常に限定的ですが、主に以下の人をターゲットしています
- 何故かcomposerが正常に動作しない
- composerでの依存解決ができない
- 競合や依存関係の破損が起こる
- クラスや名前空間を使用したくない
- composerを使用したくない
- カスタムしたい
- 不要な部分を削って少しでもリソース節約したい(出来るかはしらんが)
- 他人のライブラリに依存したくない方
- ライブラリを騙ったマルウェアが怖い方
- 意味の分からない事をしたい変態さん
- 変態さんになりたい初心者さん
tl;dr
PHPではStream Socketと、SMTPコマンドを使うと簡単にメール送信ができ、比較的低レイヤー操作のため、自由度が高い。
前置
composerの地獄
PHPで何か高度なことをするときにほぼ必ずと言って良いほど必要なcomposer。
Node.jsでは内蔵されているパッケージマネージャーもPHPでは有志が作り上げた後付けのもので、設定や拡張によっては動作しないことも多く、そもそも依存パッケージや自動ロードという概念が標準にはないPHPではその設定の面倒さから個人的には地獄と表現しています。
有名(?)な例でLaravelを使用する際にPHP-PSRがロードされていると何故か正常な動作をしなくなるなど、ライブラリとエクステンションでアプリケーションを破壊することもあり、秋雪 こおりが一度メンテナンスしたサイトにおいても環境移行時に問題が発生しました。
ISnow Novelsの実装コンセプト
Lilith Systemの実装コンセプトに、「素のPHPをできる限り自力で使用する」というのがあります。
実装が面倒くさいMarkdownパーサー(Lilith SystemではParsedownを使用)はともかくLilith Systemでは独自のコードに名前空間もクラスも使用しておらず、独自のローダーを使用して実行されるようになっています。
小説のパーサーやデータベース操作、テンプレートエンジンに至るまですべてISnow Systemsで実装しています。
本題
メール送信関数mail()
について
PHP標準にはメール送信関数である、mail()
があります。
この関数では、Windowsではソケット操作で、それ以外ではsendmail
コマンドを使用して送信するものとなっています。また、本文は76バイト毎に改行されている必要があり、一部のメールアドレスは安全のため使用できません。(前者はMIMEの仕様のせい、後者はescapeshellcmd()
のせい)
sendmail
コマンドへのパスを指定した上で、sendmail
がインストール、設定されている必要があり、どうしても外部コマンドを使用することになります。(間違っていたらごめんなさい)
また、同様にマルチバイト対応のmb_send_mail()
もありますが、エンコードの変換をする機能がついただけのmail()
のラッパー関数なので同じくメールアドレスの制限があり、外部コマンドを呼び出す形になります。
Succubus Wing
淫魔の羽という名前をもつこのメール送信サブシステムは同じく淫魔の名を冠するLilith Systemの為に設計されたものです。
このサブシステムはいずれ、GitHubにて正式にライブラリとして公開する予定ですが、解説ついでに公開しようと思います。
SMTPについては、別の方の記事を参照していただければと思います。(解説する記事は多いが、telnet
を使う奴が一番趣旨に近い)
フローチャート
大まかには次のような流れです。
定数値の解説
-
SMTP_HOST
メールサーバーのアドレスです。ポート番号を含める必要があります
形式は[host]:[port]
で、スキーマやパスなどは不要です -
CLIENT_HOST
クライアントのホスト名です。EHLO(拡張HELLOコマンド)に使います -
SUCCUBUSWING_SEND_FROM
送信元アドレスです。メールアドレスはRFC5321/5322に準拠するものである必要があります -
SUCCUBUSWING_SENDER_NAME
送信者の名前です。一般的にサービス名を記述します -
SUCCUBUSWING_APP_VER_GENMETA
送信元のユーザーエージェントです
X-Mailer
で使用されます。この定数は変更不要です -
SUCCUBUSWING_LOGIN
SMTP-AUTHの認証ID
define()
なのは、.envからの読み込みに対応するためです -
SUCCUBUSWING_PASSWD
SMTP=AUTHのパスワード
define()
なのは、.envからの読み込みに対応するためです -
SUCCUBUSWING_MESSAGE_ID_LOCAL_PART
メッセージIDのサービス名部分です -
SUCCUBUSWING_MESSAGE_ID_DOMAIN
メッセージIDのドメイン部分です
使い方
require
または、include
で読み込んでから次の関数で送信できます。
sendMailSocket("受信者", "recipient@example.com", "件名", "<html><body><p>HTML 本文</p></body></html>", "平文テキスト 本文", ["Custom-Header" => "Header-Value"])
改造次第ではCC/BCCや複数のTOにも対応できます。
再現・やり方
PHPのソケット操作には二種類あります。
今回はSTARTTLSを使う都合上、Stream Socketを使います。
Stream Socketでは、OpenSSLの暗号化機能が使用できるため、SSL/TLSでも通信することができます。
また、SMTPのポートは一般的にサーバー間通信用の25/TCP, 暗黙的な暗号化を行う465/TCP, 提出用(Submission)ポート587/TCPがありますが、STARTTLSとSMTP-AUTHを使うこと、メールサーバーとアプリケーションサーバーを分離できることを目的に作ったコードのため、ポートは587を使います。
そのため、メールサーバー宛てにtelnet
やopenssl s_client
でSMTPコマンドを打つようにソケット操作するコードを書くだけで、今回のコードの再現ができます。
ソケット通信という比較的低レイヤーな操作の為、自由度は高いものの、不正なデータや不正なコマンドの対策をしないと、最悪脆弱性に繋がることもあります。
コード全体
無駄しかないコードなので、使われる場合は各々で最適化してください。
また、改変を加えた後テストをしていないので、動作しない可能性もありますが、修正してお使いください。
<?php
/*
Open Succubus Wing v5
version 5.99.201
Copyright (c) 2021, 2023 ISnow Systems / Kori Syusetu
Copyright (c) 2023 Shota Sakitsu
*/
// tcp://{SMTP_HOST}
const SMTP_HOST = "mail.example.com:587";
// EHLO {CLIENT_HOST}
const CLIENT_HOST = "ourservice.example.com";
// {SUCCUBUSWING_SENDER_NAME}<{SUCCUBUSWING_SEND_FROM}>
const SUCCUBUSWING_SEND_FROM = "no-reply@example.com";
const SUCCUBUSWING_SENDER_NAME = "Our Service";
// {random_id}.{SUCCUBUSWING_MESSAGE_ID_LOCAL_PART}.open-succubus-wing@{SUCCUBUSWING_MESSAGE_ID_DOMAIN}
const SUCCUBUSWING_MESSAGE_ID_LOCAL_PART = "our.service";
const SUCCUBUSWING_MESSAGE_ID_DOMAIN = "example.com";
const SUCCUBUSWING_MESSAGE_ID_BRAND = "open-succubus-wing"; //触らないでください
// SMTP-AUTH User Information
define("SUCCUBUSWING_LOGIN", "Type your secret function");
define("SUCCUBUSWING_PASSWD", "Type your secret function");
// X-Mailer: OpenSuccubusWing/5.99 [ja]
const SUCCUBUSWING_APP_VER_GENMETA = "OpenSuccubusWing/5.99"; //触らないでください
/**
* メールを送信します
*
* TCPソケットを使用してメールを送信します。
* STARTTLSに最適化しています。
*
* @param string $recipientName 受信者の名前
* @param string $recipientAddress 受信者のアドレス
* @param string $originalSubject メールの件名
* @param string $originalHtmlBody HTML形式の本文
* @param string $originalPlainBody 平文テキスト形式の本文
* @param array $headers 追加のメールヘッダ
* @return void
* @throws Exception
*/
function sendMailSocket(
string $recipientName,
string $recipientAddress,
string $originalSubject,
string $originalHtmlBody,
string $originalPlainBody,
array $headers
):void
{
$htmlBodyBase64 = base64_encode($originalHtmlBody);
$htmlBodyMime = str_split($htmlBodyBase64, 76);
$htmlBody = "";
foreach ($htmlBodyMime as $value) {
$htmlBody .= "$value\r\n";
}
$plainBodyBase64 = base64_encode($originalPlainBody);
$plainBodyMime = str_split($plainBodyBase64, 76);
$plainTextBody = "";
foreach ($plainBodyMime as $value) {
$plainTextBody .= "$value\r\n";
}
$subjectBase64 = base64_encode($originalSubject);
$subject = "=?UTF-8?B?$subjectBase64?=";
$displayNameBase64 = base64_encode($recipientName);
$displayName = "=?UTF-8?B?$displayNameBase64?=";
$messageId = generateMailId();
$useEnhancedHello = true;
$mailBoundary = "------" . base_convert(bin2hex(random_bytes(5)), 16, 32) . base_convert(bin2hex(random_bytes(5)), 16, 32) . base_convert(bin2hex(random_bytes(5)), 16, 32) . base_convert(bin2hex(random_bytes(5)), 16, 32);
$socket = stream_socket_client("tcp://" . SMTP_HOST, $errorCode, $errorMessage, 30);
if (!is_resource($socket)) throw new Exception("Connection Failed");
usleep(100);
fwrite($socket, "EHLO " . CLIENT_HOST . "\r\n");
usleep(100);
if (!str_starts_with(stream_get_contents($socket), "250")) {
$useEnhancedHello = false;
fwrite($socket, "HELO " . CLIENT_HOST . "\r\n");
if (!str_starts_with(stream_get_contents($socket), "250")) throw new Exception("Client Hello Failed");
}
fwrite($socket, "STARTTLS\r\n");
if (!str_starts_with(stream_get_contents($socket), "220")) throw new Exception("STARTTLS Failed");
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT);
stream_get_contents($socket);
if ($useEnhancedHello) {
fwrite($socket, "EHLO " . CLIENT_HOST . "\r\n");
if (!str_starts_with(stream_get_contents($socket), "250")) fwrite($socket, "HELO " . CLIENT_HOST . "\r\n");
} else {
fwrite($socket, "HELO " . CLIENT_HOST . "\r\n");
if (!str_starts_with(stream_get_contents($socket), "250")) throw new Exception("Client Hello Failed");
}
fwrite($socket,"AUTH LOGIN\r\n");
if (!str_starts_with(stream_get_contents($socket), "334")) throw new Exception("Authentication System Error");
fwrite($socket,base64_encode(SUCCUBUSWING_LOGIN) . "\r\n");
if (!str_starts_with(stream_get_contents($socket), "334")) throw new Exception("Authentication System Error");
fwrite($socket,base64_encode(SUCCUBUSWING_PASSWD) . "\r\n");
if (!str_starts_with(stream_get_contents($socket), "235")) throw new Exception("Authentication Failed");
fwrite($socket,"MAIL FROM:<" . SUCCUBUSWING_SEND_FROM . ">\r\n");
if (!str_starts_with(stream_get_contents($socket), "250")) throw new Exception("MAIL Command Rejected");
fwrite($socket,"RCPT TO:<" . $recipientAddress . ">\r\n");
if (!str_starts_with(stream_get_contents($socket), "250")) throw new Exception("RCPT Command Rejected");
fwrite($socket,"DATA\r\n");
if (!str_starts_with(stream_get_contents($socket), "354")) throw new Exception("DATA Command Rejected");
$sendData = "From: " . SUCCUBUSWING_SENDER_NAME . "<" . SUCCUBUSWING_SEND_FROM . ">\r\n";
$sendData .= "To: $displayName<$recipientAddress>\r\n";
$sendData .= "Date: " . (new DateTime())->format("r") . "\r\n";
$sendData .= "Subject: $subject\r\n";
$sendData .= "Message-ID: $messageId\r\n";
$sendData .= "MIME-Version: 1.0\r\n";
$sendData .= "X-Mailer: " . SUCCUBUSWING_APP_VER_GENMETA . " [ja]\r\n";
foreach ($headers as $key => $value) {
$sendData .= "$key: $value\r\n";
}
$sendData .= "Content-Type: multipart/alternative;";
$sendData .= " boundary=\"$mailBoundary\"\r\n\r\n";
$sendData .= "--$mailBoundary\r\n";
$sendData .= "Content-Type: text/plain; charset=UTF-8\r\n";
$sendData .= "Content-Transfer-Encoding: base64\r\n\r\n";
$sendData .= $plainTextBody;
$sendData .= "\r\n\r\n";
$sendData .= "--$mailBoundary\r\n";
$sendData .= "Content-Type: text/html; charset=UTF-8\r\n";
$sendData .= "Content-Transfer-Encoding: base64\r\n\r\n";
$sendData .= $htmlBody;
$sendData .= "--$mailBoundary--\r\n";
$sendData .= ".\r\n";
fwrite($socket, $sendData);
if (!str_starts_with(stream_get_contents($socket), "250")) throw new Exception("Queue Rejected");
fwrite($socket,"QUIT\r\n");
if (!str_starts_with(stream_get_contents($socket), "221")) throw new Exception("Close Error");
fclose($socket);
}
/**
* メッセージIDを生成します。
*
* @return string メッセージIDです。
* @throws Exception
*/
function generateMailId():string{
return generateUniqueId() . "." . SUCCUBUSWING_MESSAGE_ID_LOCAL_PART . "." . SUCCUBUSWING_MESSAGE_ID_BRAND . "@" . SUCCUBUSWING_MESSAGE_ID_DOMAIN;
}
/**
* 乱数からユニークIDを生成します。
*
* @return string ユニークIDです。
* @throws Exception
*/
function generateUniqueId():string{
$buffer = "";
for ($i = 0; $i < 12; $i++) {
$buffer .= base_convert(random_int(0,35), 10, 36);
}
return $buffer;
}