Raiju/api/SmtpMailer.php
2026-10-25 06:05:42 +03:00

303 lines
9.1 KiB
PHP

<?php
/**
* Simple SMTP mailer class (without external dependencies)
* Supports TLS/SSL encryption
**/
class SmtpMailer {
private $host;
private $port;
private $username;
private $password;
private $encryption;
private $socket;
private $lastError = '';
private $debug = false;
private $logFunction = null;
public function __construct($host, $port, $username, $password, $encryption = 'tls') {
$this->host = $host;
$this->port = $port;
$this->username = $username;
$this->password = $password;
$this->encryption = strtolower($encryption);
}
public function setDebug($debug) {
$this->debug = $debug;
}
public function setLogFunction($func) {
$this->logFunction = $func;
}
private function log($message) {
if ($this->logFunction && is_callable($this->logFunction)) {
call_user_func($this->logFunction, $message);
}
if ($this->debug) {
error_log($message);
}
}
public function getLastError() {
return $this->lastError;
}
/**
* Sends an email via SMTP
*/
public function send($to, $subject, $body, $fromEmail, $fromName = '', $replyTo = '', $bcc = []) {
try {
// Connect to the server
if (!$this->connect()) {
return false;
}
// Send EHLO
if (!$this->ehlo()) {
$this->disconnect();
return false;
}
// Start TLS if necessary
if ($this->encryption === 'tls') {
if (!$this->startTls()) {
$this->disconnect();
return false;
}
// After STARTTLS, send EHLO again
if (!$this->ehlo()) {
$this->disconnect();
return false;
}
}
// authentication
if (!$this->authenticate()) {
$this->disconnect();
return false;
}
// Send email
if (!$this->sendMail($to, $subject, $body, $fromEmail, $fromName, $replyTo, $bcc)) {
$this->disconnect();
return false;
}
$this->disconnect();
return true;
} catch (Exception $e) {
$this->lastError = $e->getMessage();
$this->disconnect();
return false;
}
}
private function connect() {
$host = $this->host;
// SSL prefix for port 465
if ($this->encryption === 'ssl') {
$host = 'ssl://' . $host;
}
$this->log("Connecting to $host:$this->port ...");
$this->socket = @fsockopen($host, $this->port, $errno, $errstr, 30);
if (!$this->socket) {
$this->lastError = "Connection failed: $errstr ($errno)";
$this->log("ERROR: " . $this->lastError);
return false;
}
$this->log("Connection established, waiting for greeting...");
// Read server greeting
$response = $this->getResponse();
if (substr($response, 0, 3) !== '220') {
$this->lastError = "Invalid server response: $response";
$this->log("ERROR: " . $this->lastError);
return false;
}
$this->log("Server greeting OK");
return true;
}
private function disconnect() {
if ($this->socket) {
$this->sendCommand("QUIT");
fclose($this->socket);
$this->socket = null;
}
}
private function ehlo() {
$hostname = isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'localhost';
// Never let a spoofed Host/SERVER_NAME inject SMTP commands via CR/LF.
$hostname = preg_replace('/[^A-Za-z0-9.\-\[\]:]/', '', (string)$hostname);
if ($hostname === '') $hostname = 'localhost';
$response = $this->sendCommand("EHLO $hostname");
return substr($response, 0, 3) === '250';
}
private function startTls() {
$response = $this->sendCommand("STARTTLS");
if (substr($response, 0, 3) !== '220') {
$this->lastError = "STARTTLS failed: $response";
return false;
}
// Enable TLS encryption
$crypto = stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
if (!$crypto) {
$this->lastError = "Failed to enable TLS encryption";
return false;
}
return true;
}
private function authenticate() {
// AUTH LOGIN
$response = $this->sendCommand("AUTH LOGIN");
if (substr($response, 0, 3) !== '334') {
$this->lastError = "AUTH LOGIN failed: $response";
return false;
}
// Username (Base64)
$response = $this->sendCommand(base64_encode($this->username));
if (substr($response, 0, 3) !== '334') {
$this->lastError = "Username rejected: $response";
return false;
}
// Password (Base64)
$response = $this->sendCommand(base64_encode($this->password));
if (substr($response, 0, 3) !== '235') {
$this->lastError = "Authentication failed: $response";
return false;
}
return true;
}
private function normalizeRecipients($value) {
$rawItems = is_array($value) ? $value : explode(',', (string)$value);
$recipients = [];
foreach ($rawItems as $item) {
foreach (explode(',', (string)$item) as $email) {
$email = trim(str_replace(["\r", "\n", "\t"], '', (string)$email));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$recipients[] = $email;
}
}
}
return array_values(array_unique($recipients));
}
private function formatRecipientHeader(array $recipients) {
return implode(', ', array_map(function ($email) {
return '<' . $email . '>';
}, $recipients));
}
private function sendMail($to, $subject, $body, $fromEmail, $fromName, $replyTo, $bcc = []) {
$toRecipients = $this->normalizeRecipients($to);
$bccRecipients = $this->normalizeRecipients($bcc);
if (empty($toRecipients)) {
$this->lastError = 'No valid recipient address';
return false;
}
// MAIL FROM
$response = $this->sendCommand("MAIL FROM:<$fromEmail>");
if (substr($response, 0, 3) !== '250') {
$this->lastError = "MAIL FROM rejected: $response";
return false;
}
// BCC recipients are envelope recipients only and are not written to headers.
foreach (array_merge($toRecipients, $bccRecipients) as $recipient) {
$response = $this->sendCommand("RCPT TO:<$recipient>");
if (!in_array(substr($response, 0, 3), ['250', '251'], true)) {
$this->lastError = "RCPT TO rejected for $recipient: $response";
return false;
}
}
// DATA
$response = $this->sendCommand("DATA");
if (substr($response, 0, 3) !== '354') {
$this->lastError = "DATA rejected: $response";
return false;
}
// Email headers and body
$headers = [];
$headers[] = "Date: " . date('r');
$headers[] = "From: " . ($fromName ? "=?UTF-8?B?" . base64_encode($fromName) . "?= <$fromEmail>" : $fromEmail);
$headers[] = "To: " . $this->formatRecipientHeader($toRecipients);
$headers[] = "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=";
if ($replyTo) {
$headers[] = "Reply-To: <$replyTo>";
}
$headers[] = "MIME-Version: 1.0";
$headers[] = "Content-Type: text/plain; charset=UTF-8";
$headers[] = "Content-Transfer-Encoding: 8bit";
$headers[] = "X-Mailer: SmtpMailer/1.0";
$message = implode("\r\n", $headers) . "\r\n\r\n" . $body;
// Escape dots at beginning of lines (SMTP protocol)
$message = str_replace("\n.", "\n..", $message);
// Send message and terminate with .
fwrite($this->socket, $message . "\r\n.\r\n");
$response = $this->getResponse();
if (substr($response, 0, 3) !== '250') {
$this->lastError = "Email not accepted: $response";
return false;
}
return true;
}
private function sendCommand($command) {
// Don't log password
$logCmd = (strpos($command, base64_encode($this->password)) !== false)
? '[PASSWORD]'
: $command;
$this->log("SMTP > $logCmd");
fwrite($this->socket, $command . "\r\n");
return $this->getResponse();
}
private function getResponse() {
$response = '';
while ($line = fgets($this->socket, 515)) {
$response .= $line;
// Check if last line (4th character is a space)
if (isset($line[3]) && $line[3] === ' ') {
break;
}
}
$this->log("SMTP < " . trim($response));
return $response;
}
}