…]) */ private array $tls = []; /** * コンストラクタ * * @param array|null $info MAILINFOの代わりの設定(テスト用。指定した場合はMAILER_ENABLEDを確認しない) */ public function __construct(?array $info = null) { if ($info === null && !MAILER_ENABLED) { throw new \Exception("メーラーは無効です。"); } $info ??= MAILINFO; $this->host = (string)($info['host'] ?? ''); $this->port = (int)($info['port'] ?? 587); $this->user = $info['user'] ?? null; $this->pass = $info['pass'] ?? null; $this->from = (string)($info['from'] ?? ''); $this->tls = (array)($info['tls'] ?? []); } /** * 接続・認証・送信・切断を1回で行う(例外はResultにする) * * @param string $to 受信者のメールアドレス * @param string $subject 件名 * @param string $body 本文 * @param string|null $toName 受信者の名前 * @return Result */ public static function deliver(string $to, string $subject, string $body, ?string $toName = null): Result { try { $mail = new self(); $mail->connect(); try { $mail->authenticate(); $mail->send($to, $subject, $body, $toName); } finally { $mail->disconnect(); } return Result::Success('メールを送信しました。'); } catch (\Throwable $e) { logger(LogType::Mailer, "「{$to}」への送信に失敗:".$e->getMessage()); return Result::Error('メールの送信に失敗しました。'); } } /** * ソケット接続を開く。 * * @return void */ public function connect(): void { $this->socket = @fsockopen($this->host, $this->port, $errno, $err, $this->timeout); if (!$this->socket) { $msg = "接続に失敗: {$err} ({$errno})"; logger(LogType::Mailer, $msg); throw new \Exception($msg); } stream_set_timeout($this->socket, $this->timeout); $this->expectGreeting(); } /** * 既に開いているストリームを使う(テストやプロキシ用) * * @param resource $stream SMTPサーバーに繋がったストリーム * @return void */ public function useStream($stream): void { $this->socket = $stream; $this->expectGreeting(); } private function expectGreeting(): void { $res = $this->readResponse(); if (substr($res, 0, 3) !== '220') { $msg = "予期しない挨拶: {$res}"; logger(LogType::Mailer, $msg); throw new \Exception($msg); } } /** * ソケット接続を切断する。 * * @return void */ public function disconnect(): void { if (!is_resource($this->socket)) return; try { $this->sendCommand('QUIT', 221); } catch (\Exception $e) { // 切断の失敗は無視する } fclose($this->socket); } /** * サーバーで認証する。 * ユーザー名とパスワードが指定されている場合はAUTH LOGINを使用する。 * * @return void */ public function authenticate(): void { // EHLOには相手ではなく自分のホスト名を名乗る $me = gethostname() ?: 'localhost'; $ehloRes = $this->sendCommand('EHLO '.$me, 250); // STARTTLSは対応するかどうか確認 if (strpos($ehloRes, 'STARTTLS') !== false) { $this->sendCommand('STARTTLS', 220); // TLS暗号化(TLS 1.3・1.2、証明書はサーバーのホスト名で確認する) try { $this->socket = Tls::enable($this->socket, $this->tls + ['peer_name' => $this->host]); } catch (TlsException $e) { $msg = "TLSハンドシェイクに失敗: ".$e->getMessage(); logger(LogType::Mailer, $msg); throw new \Exception($msg); } $this->sendCommand('EHLO '.$me, 250); } if ($this->user && $this->pass) { $this->sendCommand('AUTH LOGIN', 334); $this->sendCommand(base64_encode($this->user), 334, true); $this->sendCommand(base64_encode($this->pass), 235, true); } } /** * メールを送信する。 * * @param string $to 受信者のメールアドレス。 * @param string $subject メールの件名。 * @param string $body メールの本文。 * @param string|null $toName 受信者の名前。 * @param string|null $replyTo 返信先メールアドレス。 * @param string|null $replyToName 返信先の名前。 * @param bool $pgpSign PGPで署名するかどうか(現在は正常に動作していません)。 * * @return void * @throws \InvalidArgumentException アドレスや名前に改行等が含まれる場合(ヘッダー注入対策) */ public function send( string $to, string $subject, string $body, ?string $toName = null, ?string $replyTo = null, ?string $replyToName = null, bool $pgpSign = false ): void { $from = $this->from; foreach ([$from, $to, $replyTo] as $addr) { if ($addr !== null && !self::isSafeAddress($addr)) throw new \InvalidArgumentException("不正なメールアドレス: {$addr}"); } foreach ([$toName, $replyToName, $subject] as $text) { if ($text !== null && preg_match('/[\r\n\0]/', $text)) throw new \InvalidArgumentException('件名や名前に改行は使えません。'); } $this->sendCommand("MAIL FROM: <{$from}>", 250); $this->sendCommand("RCPT TO: <{$to}>", 250); $this->sendCommand('DATA', 354); $headers = "Date: ".date('r')."\r\n"; // RFC 2822 // Message-IDが無いと迷惑メール扱いされ易い $domain = substr(strrchr($from, '@') ?: '@localhost', 1); $headers .= "Message-ID: <".bin2hex(random_bytes(16))."@{$domain}>\r\n"; $encSubject = mb_encode_mimeheader($subject, 'UTF-8', 'Q'); $headers .= "Subject: {$encSubject}\r\n"; $fromHeader = mb_encode_mimeheader(SITEINFO['title'], 'UTF-8', 'Q')." <{$from}>"; $headers .= "From: {$fromHeader}\r\n"; $toHeader = $toName ? mb_encode_mimeheader($toName, 'UTF-8', 'Q')." <{$to}>" : $to; $headers .= "To: {$toHeader}\r\n"; if ($replyTo) { $replyToHeader = $replyToName ? mb_encode_mimeheader($replyToName, 'UTF-8', 'Q')." <{$replyTo}>" : $replyTo; $headers .= "Reply-To: {$replyToHeader}\r\n"; } $headers .= "MIME-Version: 1.0\r\n"; if ($pgpSign) { $boundary = uniqid('BOUNDARY_'); $headers .= "Content-Type: multipart/signed;\r\n"; $headers .= " protocol=\"application/pgp-signature\";\r\n"; $headers .= " micalg=php-sha512;\r\n"; $headers .= " boundary=\"{$boundary}\"\r\n"; } else { $headers .= "Content-Type: text/plain; charset=utf-8\r\n"; $headers .= "Content-Transfer-Encoding: quoted-printable\r\n"; } $headers .= "X-Mailer: 076 Little Beast\r\n"; // 改行をCRLFに揃えてからエンコードする $encBody = quoted_printable_encode(preg_replace('/\r\n|\r|\n/', "\r\n", $body)); $data = $headers."\r\n".$encBody; if ($pgpSign) { $signature = $this->signMessage($data); $data = "--{$boundary}\r\n" .$data."\r\n" ."Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n" ."Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n" .$signature."\r\n" ."--{$boundary}--\r\n"; } // ドットスタッフィング:「.」で始まる行は「..」にする(「.」だけの行で本文が途中で終わらない様に) $data = preg_replace('/^\./m', '..', $data); $data .= "\r\n.\r\n"; fwrite($this->socket, $data); $response = $this->readResponse(); if (substr($response, 0, 3) != '250') { $msg = "メール送信に失敗: {$response}"; logger(LogType::Mailer, $msg); throw new \Exception($msg); } } /** * お好みのタイムゾーンを設定する。 * 設定しない場合、php.iniで設定されたタイムゾーンがデフォルトになる。 * それも設定されていない場合、GMTタイムゾーンがデフォルトになる。 * * @param string $zone IANAタイムゾーンデータベース標準のタイムゾーン(例:Asia/Tokyo) * @return void */ public function setTimezone(string $zone): void { date_default_timezone_set($zone); } /** * PGPキーとオプションでパスフレーズを設定する。 * * @param string $keypath PGP署名に使用する秘密鍵へのパス。 * @param string|null $passphrase 設定されている場合、署名用のパスフレーズ。 * * @return void */ public function enablePGP(string $keypath, ?string $passphrase = null): void { $this->pgpKey = file_get_contents($keypath); $this->pgpPass = $passphrase; } // 機能性メソッド private function sendCommand(string $command, int $retcode, bool $secret = false): string { fwrite($this->socket, $command."\r\n"); $res = $this->readResponse(); if (substr($res, 0, 3) != $retcode) { // 認証情報をログに残さない $shown = $secret ? '(認証情報)' : $command; $msg = "「{$shown}」に対する予期しないレスポンス: {$res}"; logger(LogType::Mailer, $msg); throw new \Exception($msg); } return $res; } private function readResponse(): string { $res = ''; while (($line = fgets($this->socket, 515)) !== false) { $res .= $line; if (substr($line, 3, 1) == ' ') break; // レスポンスの終了だ } return $res; } /** * SMTPのコマンドに入れても安全なメールアドレスか(改行や<>を含まない) */ private static function isSafeAddress(string $addr): bool { return $addr !== '' && !preg_match('/[\r\n\0<>\s]/', $addr) && str_contains($addr, '@'); } private function signMessage(string $message): string { if (extension_loaded('gnupg')) {// gnupg延長は有効の場合 $gpg = new \gnupg(); $gpg->addsignkey($this->pgpKey, $this->pgpPass); $gpg->setsignmode(\gnupg::SIG_MODE_DETACH); return $gpg->sign($message); } else { // なければ、CLIツールを使う(gnupgをインストールは必須) $tmp = tempnam(sys_get_temp_dir(), 'pgpmsg'); file_put_contents($tmp, $message); $sig = shell_exec( "gpg --batch " .($this->pgpPass ? "--passphrase ".escapeshellarg($this->pgpPass)." " : '') ."--detach-sign --armor ".escapeshellarg($tmp)." 2>&1" ); unlink($tmp); return $sig; } } }