独自のPHP用ソケット通信式メール送信ライブラリとその解説

バーチャルプログラマ・技術者 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`を使う奴が一番趣旨に近い)

### フローチャート
大まかには次のような流れです。
```mermaid
flowchart TD;
start([関数呼び出し])
pre-process[引数等事前処理]
connection[SMTP接続]
if-connected{接続できたか}
exception1([接続失敗による例外のスロー])
plain-ehello[EHLOの送信]
plain-hello[HELOの送信]
start-tls[STARTTLSの実行]
secure-ehello[EHLOの再送信]
secure-hello[HELOの再送信]
login[SMTP-AUTHのログイン処理]
mail-command[メール送信の準備]
data-command[データコマンドの実行]
if-data-no-fail{本文を受け付けているか}
close-error[SMTP接続の終了]
exception2([受付拒否による例外のスロー])
write-body[本文の出力]
close-connection[SMTP接続の終了]
done([完了])

start --> pre-process -->connection
connection --> if-connected
if-connected -- "いいえ" ---> exception1
if-connected -- "はい" ---> plain-ehello
plain-ehello -. "フォールバック" .-> plain-hello
plain-ehello --> start-tls
plain-hello --> start-tls
start-tls --> secure-ehello
secure-ehello -. "フォールバック" .-> secure-hello
secure-ehello --> login
secure-hello --> login
login --> mail-command
mail-command --> data-command --> if-data-no-fail
if-data-no-fail -- "いいえ" ---> close-error --> exception2
if-data-no-fail -- "はい" ---> write-body
write-body --> close-connection --> done
```

### 定数値の解説
- `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:mail.php
<?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;
}
```