1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

VTuber・バーチャルプログラマ・技術者Advent Calendar 2023

Day 4

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

Last updated at Posted at 2023-12-03

バーチャルプログラマ・技術者 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を使います。
そのため、メールサーバー宛てにtelnetopenssl s_clientでSMTPコマンドを打つようにソケット操作するコードを書くだけで、今回のコードの再現ができます。
ソケット通信という比較的低レイヤーな操作の為、自由度は高いものの、不正なデータや不正なコマンドの対策をしないと、最悪脆弱性に繋がることもあります。

コード全体

無駄しかないコードなので、使われる場合は各々で最適化してください。
また、改変を加えた後テストをしていないので、動作しない可能性もありますが、修正してお使いください。

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;
}
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?