<?php

namespace Smtpd;

// 

class Message {
	public $from = '';
	public $to = [];
	public $body = '';
}

//

class Connection {
	
	const PS_INVALID = 0;
	const PS_AWAITING_IDENTIFICATION = 1;
	const PS_AWAITING_NEW_TOPIC = 2;
	const PS_AWAITING_MAIL_PROPERTIES = 3;
	const PS_AWAITING_MAIL_BODY = 4;
	
	protected $protocol_state = self::PS_INVALID;
	
	protected $socket = null;
	
	protected $peer_addr;
	
	protected $peer_port;

	protected $peer_description;
	
	/**
	 * @var string $buffer
	 */
	protected $buffer;
	
	/**
	 * @var Smtpd\Message $current_message;
	 */
	protected $current_message = null;
	
	/**
	 * @var Smtpd\Server $server
	 */
	protected $server = null;
	
	protected function __construct() {} 
	
	public function __destruct() {
		$this->shutdown();
	}
	
	/**
	 * @return Smtpd\Connection
	 */
	public static function createFrom($socket, Server $server) {
		$sc = new Connection();
		
		$sc->socket = $socket;
		socket_getpeername($socket, $sc->peer_name, $sc->peer_port);
		$sc->server = $server;
		$sc->send220Hello();
		$sc->protocol_state = self::PS_AWAITING_IDENTIFICATION;
		
		return $sc;
	}
	
	public function parse($buffer) {
		if ($this->socket !== null) {
			$this->buffer .= $buffer;
			$this->processProtocol();
		}
	}
	
	protected function processProtocol() {
		
		while(strlen($this->buffer)) {
			
			switch($this->protocol_state) {
				
				case self::PS_AWAITING_IDENTIFICATION: {
					if (! $this->parseClientHello()) {
						return;
					}
				} break;
				
				case self::PS_AWAITING_NEW_TOPIC: {
					if (! $this->parseNewTopic()) {
						return;
					}
				} break;
				
				case self::PS_AWAITING_MAIL_PROPERTIES: {
					if (! $this->parseMailProperties()) {
						return;
					}
				} break;
				
				case self::PS_AWAITING_MAIL_BODY: {
					if (! $this->parseMailBody()) {
						return;
					}
				} break;
				
				default: {
					throw new Exception("Unexpected internal state");
				}
			}
			
		}
	}
	
	public function hasShutdown() {
		return ($this->socket === null);
	}
	
	protected function shutdown() {
		if ($this->socket !== null) {
			echo "Connection shutdown.\n";
			socket_shutdown($this->socket, 2);
			socket_close($this->socket);
			$this->socket = null;
		}
		return false;
	}
	
	protected function parseClientHello() {
		$line = $this->readUntilCRLF();
		if ($line === false) {
			return false; // don't continue
		}
		
		$matches = [];
		if (preg_match('~^[HE]{2}LO ([^ ]+)$~', $line, $matches)) {
			$this->peer_description = $matches[1];
			$this->send250OK();
			$this->protocol_state = self::PS_AWAITING_NEW_TOPIC;
			return true; // keep parsing
			
		} else {
			return $this->shutdown();
		}
	}
	
	protected function parseNewTopic() {
		
		$line = $this->readUntilCRLF();
		if ($line === false) {
			return false; // don't continue
		}
		
		// In this state, the server might initiate a new mail or it might quit
		
		$matches = [];
		if (preg_match('~^MAIL FROM:<([^>]+)>$~', $line, $matches)) {
			$this->current_message = new Message();
			$this->current_message->from = $matches[1];			
			$this->send250OK();
			$this->protocol_state = self::PS_AWAITING_MAIL_PROPERTIES;
			return true; // keep parsing
			
		} elseif (preg_match('~^QUIT$~', $line, $matches)) {
			$this->send221Bye();
			return $this->shutdown(); // close
			
		} else {
			return $this->shutdown(); // malformed
			
		}
	}
	
	protected function parseMailProperties() {
		
		$line = $this->readUntilCRLF();
		if ($line === false) {
			return false; // don't continue
		}
		
		if (preg_match('~^RCPT TO:<([^>]+)>$~', $line, $matches)) {
			$this->current_message->to[] = $matches[1];
			$this->send250OK();
			return true;
			
		} elseif (preg_match('~^DATA$~', $line, $matches)) {
			$this->send354DataStart();
			$this->protocol_state = self::PS_AWAITING_MAIL_BODY;
			return true;
			
		} else {
			return $this->shutdown(); // malformed
		}
	}
	
	protected function parseMailBody() {
		for(;;) {
			$line = $this->readUntilCRLF();
			if ($line === false) {
				return false; // Done for now until we get more data
			}
			
			if (strlen($line) == 1 && $line[0] == '.') {
				
				// End of message
				$this->server->onNewMessageRecieved( $this->current_message );
				$this->send250OK();
				$this->protocol_state = self::PS_AWAITING_NEW_TOPIC;
				
				return true; // continue parsing
				
			} else {
				
				if (strlen($line) >= 2 && $line[0] == '.' && $line[1] == '.') {
					$line = substr($line, 1);
				}
				
				$this->current_message->body .= $line."\r\n";
				continue; // loop
				
			}
		}
	}
	
	protected function readUntilCRLF() {
		$rn_pos = strpos($this->buffer, "\x0D\x0A");
		
		if ($rn_pos === false) {
			return false;
			
		} else {
			$retn = substr($this->buffer, 0, $rn_pos);
			$this->buffer = substr($this->buffer, $rn_pos + 2);
			return $retn;
		}
	}
	
	protected function send220Hello() {
		$this->send(220, "%s ESMTP %s", $this->server->getHostname(), $this->server->getServerDescription());
	}
	
	protected function send250OK() {
		$this->send(250, "OK");
	}
	
	protected function send221Bye() {
		$this->send(221, "Bye");
	}
	
	protected function send354DataStart() {
		$this->send(354, "End data with <CR><LF>.<CR><LF>.");
	}
	
	protected function send($code, $formatString='', ...$specifiers) {
		if (strlen($formatString)) {
			$formatString = $code." ".$formatString;
		}
		$formatString .= "\r\n";
		
		$remaining_data = vsprintf($formatString, $specifiers);
		while(strlen($remaining_data)) {
			$written = socket_write($this->socket, $remaining_data);
			
			if ($written === false) {
				return false; // socket closed
			}
			
			$remaining_data = substr($remaining_data, $written);
		}
		return true; // all written
	}
	
}

// 

abstract class Server {
	
	private $hostname;
	
	protected $port;
	
	protected $bind_addr;
	
	public function __construct($hostname, $bind_addr='0.0.0.0', $bind_port=25) {
		$this->hostname  = (string)$hostname;
		$this->bind_addr = (string)$bind_addr;
		$this->port      = intval($bind_port);
	}
	
	abstract public function onNewMessageRecieved(Message $message);
	
	public function getHostname() {
		return $this->hostname;
	}
	
	public function getServerDescription() {
		return 'phplibsmtpd'; // overridable
	}
	
	public function run() {
		
		$listen_sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
		socket_bind($listen_sock, $this->bind_addr, $this->port);
		socket_listen($listen_sock);

		$next_idx = -1;
		$client_sockets = [ $listen_sock ];
		
		$client_objects = [
			(int)$listen_sock => null
		];
		$client_lookup = [
			(int)$listen_sock => ++$next_idx
		];
		
		$ignore = [];
    
		for(;;) {
			
			$read = array_values($client_sockets);
			if (socket_select($read, $ignore, $ignore, null /* block */) < 1) {
				continue;
			}
			
			// Non-listener socket signalled? 
			foreach($read as $read_sock) {
				
				if ($read_sock == $listen_sock) {
					
					// Accept new client connection
					$new_sock = socket_accept($listen_sock);
					$client_sockets[++$next_idx] = $new_sock; 
					$client_lookup[ (int)$new_sock ] = $next_idx;
					$client_objects[ (int)$new_sock ] = Connection::createFrom($new_sock, $this);
					
				} else {
					// Read a chunk
					$data = socket_read($read_sock, 4096, PHP_BINARY_READ);
					
					$should_destruct = false;
					
					if ($data === false || $data === '') {
						// Disconnected, destruct
						$should_destruct = true;
						
					} else {
						$client_objects[ (int)$read_sock ]->parse($data);
						
						// If in the process of parsing this data, the client object shut down, we need to remove it
						if ($client_objects[ (int)$read_sock ]->hasShutdown()) {
							$should_destruct = true;
						}
						
					}
					
					// Cleanup closed socket
					if ($should_destruct) {
						$client_idx = $client_lookup[ (int)$read_sock ];
						unset($client_sockets[$client_idx]);
						unset($client_lookup[(int)$read_sock]);
						unset($client_objects[(int)$read_sock]);
					}
				}
			}
		}
	}
	
}

//

class TestServer extends Server {
	function onNewMessageRecieved(Message $message) {
		echo "Test server recieved a new message!", PHP_EOL;
		print_r($message);
	}
}

//

$s = new TestServer('test-mail-server.example.com');
$s->run();
