You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
172 lines
7.8 KiB
172 lines
7.8 KiB
<?php
|
|
require_once __DIR__ . '/../utils/Config.php';
|
|
class SmtpHandler {
|
|
private $socket;
|
|
private $clientIp;
|
|
private $logger;
|
|
private $config;
|
|
private $state = 'init';
|
|
private $heloHost = '';
|
|
private $fromAddress = '';
|
|
private $toAddresses = [];
|
|
private $dataBuffer = '';
|
|
private $dataSize = 0;
|
|
private $maxDataSize = 10485760;
|
|
public function __construct($socket, $clientIp, $logger) {
|
|
$this->socket = $socket;
|
|
$this->clientIp = $clientIp;
|
|
$this->logger = $logger;
|
|
$this->config = Config::getInstance();
|
|
$this->maxDataSize = $this->config->get('server.max_email_size', 10485760);
|
|
}
|
|
public function handle($data) {
|
|
if ($this->state === 'data') { $this->handleDataContent($data); }
|
|
else {
|
|
$lines = explode("\r\n", $data);
|
|
foreach ($lines as $line) {
|
|
$line = trim($line);
|
|
if (empty($line)) { continue; }
|
|
$this->processCommand($line);
|
|
}
|
|
}
|
|
}
|
|
private function processCommand($command) {
|
|
$parts = preg_split('/\\s+/', $command, 2);
|
|
$cmd = strtoupper($parts[0]);
|
|
$params = isset($parts[1]) ? $parts[1] : '';
|
|
switch ($cmd) {
|
|
case 'HELO':
|
|
case 'EHLO':
|
|
$this->heloHost = $params;
|
|
$this->state = 'helo';
|
|
$this->sendResponse(250, "250 {domain} Hello {host} [{ip}]");
|
|
break;
|
|
case 'MAIL':
|
|
if ($this->state !== 'helo') { $this->sendResponse(503, "503 Bad sequence of commands"); return; }
|
|
preg_match('/^FROM:\\s*<([^>]*)>/i', $params, $matches);
|
|
if (empty($matches)) { $this->sendResponse(501, "501 Syntax error in parameters or arguments"); return; }
|
|
$sender = $matches[1];
|
|
if (!filter_var($sender, FILTER_VALIDATE_EMAIL)) { $this->sendResponse(553, "553 5.1.8 Invalid sender email address"); return; }
|
|
$this->fromAddress = $sender;
|
|
$this->state = 'mail';
|
|
$this->toAddresses = [];
|
|
$this->sendResponse(250, "250 2.1.0 Sender OK");
|
|
break;
|
|
case 'RCPT':
|
|
if ($this->state !== 'mail') { $this->sendResponse(503, "503 Bad sequence of commands"); return; }
|
|
preg_match('/^TO:\\s*<([^>]*)>/i', $params, $matches);
|
|
if (empty($matches)) { $this->sendResponse(501, "501 Syntax error in parameters or arguments"); return; }
|
|
$toAddress = $matches[1];
|
|
if (!filter_var($toAddress, FILTER_VALIDATE_EMAIL)) { $this->sendResponse(553, "553 5.1.8 Invalid recipient email address"); return; }
|
|
if (count($this->toAddresses) >= 100) { $this->sendResponse(452, "452 4.5.3 Too many recipients"); return; }
|
|
$this->toAddresses[] = $toAddress;
|
|
$this->sendResponse(250, "250 2.1.5 Recipient OK");
|
|
break;
|
|
case 'DATA':
|
|
if ($this->state !== 'mail' || empty($this->toAddresses)) { $this->sendResponse(503, "503 Bad sequence of commands"); return; }
|
|
$this->state = 'data';
|
|
$this->dataBuffer = '';
|
|
$this->dataSize = 0;
|
|
$this->sendResponse(354, "354 Start mail input; end with <CRLF>.<CRLF>");
|
|
break;
|
|
case 'RSET':
|
|
$this->state = 'helo';
|
|
$this->fromAddress = '';
|
|
$this->toAddresses = [];
|
|
$this->dataBuffer = '';
|
|
$this->dataSize = 0;
|
|
$this->sendResponse(250, "250 2.0.0 OK");
|
|
break;
|
|
case 'NOOP':
|
|
$this->sendResponse(250, "250 2.0.0 OK");
|
|
break;
|
|
case 'QUIT':
|
|
$this->sendResponse(221, "221 2.0.0 Bye");
|
|
break;
|
|
default:
|
|
$this->sendResponse(500, "500 Syntax error, command unrecognized");
|
|
break;
|
|
}
|
|
}
|
|
private function handleDataContent($data) {
|
|
$this->dataBuffer .= $data;
|
|
$this->dataSize += strlen($data);
|
|
if ($this->dataSize > $this->maxDataSize) {
|
|
$this->sendResponse(552, "552 5.2.3 Message exceeds maximum size");
|
|
$this->state = 'helo';
|
|
$this->dataBuffer = '';
|
|
$this->dataSize = 0;
|
|
return;
|
|
}
|
|
if (substr($this->dataBuffer, -5) === "\r\n.\r\n") {
|
|
$emailContent = substr($this->dataBuffer, 0, -5);
|
|
$this->processEmail($emailContent);
|
|
$this->state = 'helo';
|
|
$this->dataBuffer = '';
|
|
$this->dataSize = 0;
|
|
}
|
|
}
|
|
private function processEmail($emailContent) {
|
|
$headers = $this->parseHeaders($emailContent);
|
|
$subject = $headers['Subject'] ?? '';
|
|
$body = $this->extractBody($emailContent);
|
|
$this->saveEmail($headers, $subject, $body, $emailContent);
|
|
$this->sendResponse(250, "250 2.0.0 OK: queued as {message_id}");
|
|
}
|
|
private function parseHeaders($emailContent) {
|
|
$headers = [];
|
|
$lines = explode("\r\n", $emailContent);
|
|
foreach ($lines as $line) {
|
|
if (empty($line)) { break; }
|
|
if (preg_match('/^\\s+/', $line)) {
|
|
$last = array_key_last($headers);
|
|
$headers[$last] = isset($headers[$last]) ? $headers[$last] . ' ' . trim($line) : trim($line);
|
|
} else {
|
|
$parts = explode(':', $line, 2);
|
|
if (count($parts) === 2) {
|
|
$name = trim($parts[0]);
|
|
$value = trim($parts[1]);
|
|
$headers[$name] = $value;
|
|
}
|
|
}
|
|
}
|
|
return $headers;
|
|
}
|
|
private function extractBody($emailContent) {
|
|
$parts = explode("\r\n\r\n", $emailContent, 2);
|
|
return isset($parts[1]) ? $parts[1] : '';
|
|
}
|
|
private function saveEmail($headers, $subject, $body, $raw) {
|
|
try {
|
|
require_once __DIR__ . '/../utils/Database.php';
|
|
$db = Database::getInstance();
|
|
foreach ($this->toAddresses as $toAddress) {
|
|
$username = null;
|
|
$user = $db->fetchOne("SELECT username FROM user WHERE email = ? AND is_deleted = 0", [$toAddress]);
|
|
if ($user && isset($user['username'])) { $username = $user['username']; }
|
|
if (!$username) {
|
|
$parts = explode('@', $toAddress);
|
|
$username = $parts[0] ?? $toAddress;
|
|
}
|
|
$id = uniqid('', true);
|
|
$dateHeader = $headers['Date'] ?? date('Y-m-d H:i:s');
|
|
$toHeader = $headers['To'] ?? implode(', ', $this->toAddresses);
|
|
$fromHeader = $headers['From'] ?? $this->fromAddress;
|
|
$length = strlen($raw);
|
|
$sql = "INSERT INTO email (id, ip, helo, mail_from, rcpt_to, `from`, `to`, subject, `date`, `data`, datagram, `length`, `state`, is_read, folder, created_at, is_deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), 0)";
|
|
$db->insert($sql, [$id, $this->clientIp, $this->heloHost, $this->fromAddress, $username, $fromHeader, $toHeader, $subject, $dateHeader, $body, $raw, $length, 1, 0, 'inbox']);
|
|
}
|
|
} catch (Exception $e) {
|
|
$this->logger->error("Failed to save email: {error}", ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
private function sendResponse($code, $message) {
|
|
$domain = $this->config->get('server.domain', 'test.com');
|
|
$message = str_replace('{domain}', $domain, $message);
|
|
$message = str_replace('{ip}', $this->clientIp, $message);
|
|
$message = str_replace('{host}', $this->heloHost, $message);
|
|
$response = $message . "\r\n";
|
|
socket_write($this->socket, $response, strlen($response));
|
|
}
|
|
}
|