#!/usr/bin/php server_name = php_uname('n'); $this->source_host = $args['remote_addr']; foreach ( $args as $key => $value ) $this->$key = $value; set_time_limit( $this->timeout ); $this->send(220); $this->loop(); } function loop() { $run = true; while( $run && ($line = fgets($this->in)) ) { $line = rtrim($line); if ( empty($line) ) continue; $code = $this->parse_request($line); $run = ( 221 != $code && 421 != $code ); } if ( ! $this->check_sent_stack( self::GOODBYE ) ) $this->send(self::GOODBYE); } function send($string, $data = array() ) { static $data_defaults; if ( ! $data_defaults ) $data_defaults = array( 'domain' => $this->server_name ); if ( is_array($string) ) { if ( isset( $string[1] ) ) $data = array_merge($data, $string[1] ); $string = $string[0]; } $data = array_merge($data, $data_defaults); if ( is_int($string) ) { $this->sent_stack[] = $string; $string = $this->response( $string, $data ); // Response by Number } fwrite($this->out, $string . "\r\n"); } function parse_request($line) { $command = $payload = false; if ( preg_match('!^(EHLO|HELO|MAIL FROM|RCPT TO|DATA|RSET|NOOP|QUIT):?\s*(.+)?$!i', $line, $match_command) ) { //|VRFY|EXPN|HELP|STARTTLS - Commands that are not implemented. $command = strtoupper($match_command[1]); if ( isset($match_command[2]) ) $payload = $match_command[2]; } else { // Command not recognised $this->send( self::SYNTAX_ERROR ); return self::SYNTAX_ERROR; } // Although we don't support Extended SMTP, we're still going to accept it and treat it like a HELO to save time. if ( 'EHLO' == $command ) $command = 'HELO'; $callback = strtolower( str_replace(' ', '_', $command) ); $this->stack[] = $command; // Set us up as non-implemented $result = self::NOT_IMPLEMENTED; if ( is_callable( array($this, $callback) ) ) $result = call_user_func( array($this, $callback), $payload, $command); $this->send( $result ); return $result; } function helo($payload) { $this->source_host = trim($payload) . ' [' . $this->remote_addr . ']'; // perhaps get the frst Word of the payload? return array(self::OK, array('source-host' => $this->source_host) ); //return sprintf("%d G'Day %s, I'm at your service", self::OK, $this->source_host); } // All hail the #1 procrastinator among us, NOOP. function noop() { return self::OK; } function quit() { $this->send(self::OK); // Weird, Must reply with an OK, but then we're going to say Goodbye anyway return self::GOODBYE; } /** * Resets (RSET) may occur during a transmission to cancel the request, * however it may also be used to clear the current message (after acceptance) * and transmit a 2nd message within the same connection. */ function rset() { $this->stack = array('HELO'); $this->sent_stack = array(); // Do not reset $this->source_host as it's derived from HELO $this->source = $this->domain = $this->user = false; return self::OK; } function mail_from($payload) { if ( ! $this->check_stack('HELO') ) return self::OUT_OF_ORDER; $this->source = $this->extract_email($payload); return self::OK; } function rcpt_to($payload) { if ( ! $this->check_stack('HELO') ) return self::OUT_OF_ORDER; $email = $this->extract_email($payload); if ( ! $this->accept_mail($email) ) { $this->remove_from_stack('RCPT TO'); // Lets "forget" about this command, we don't want DATA to suceed without a valid RCPT return self::MAILBOX_UNAVAILABLE; } $pos = strrpos($email, '@'); $this->user = substr($email, 0, $pos); $this->domain = substr($email, $pos+1); $this->mailbox = $email; return self::OK; } function data() { if ( ! $this->check_stack( array( 'HELO', 'MAIL FROM', 'RCPT TO') ) ) return self::OUT_OF_ORDER; $this->send(self::DATA_AHEAD); $mail_body = ''; $run = true; while( $run && ($line = fgets($this->in)) ) { $mail_body .= $line; $run = ( "\r\n.\r\n" != substr($mail_body, -5) && /* Warning: Not to spec: */ "\n.\n" != substr($mail_body, -3) ); } // @TODO: Warning, $mail_body includes the terminator. I'll Be Back. $message = new Mail_Message($mail_body); $message->add_header( 'Received', sprintf('from %s by %s with ESMTP id %s; %s', $this->source_host, $this->server_name, $message->id, gmdate('r (T)') ), 'top' ); // from lists.luv.wordpress.org (localhost.localdomain [127.0.0.1]) by lists.luv.wordpress.org (Postfix) with ESMTP id 412544747B; Sat, 8 Oct 2011 22:48:07 +0000 (UTC) // Then go on our way. // The client may now QUIT, or issue a RSET to give us another email. // Therefor, this is the time where we now process the recieved email through the filter systems. $action_args = array( 'server' => $this, 'mail' => $message, ); // Sort the filters according to priority Mail_Action::sort($this->actions); foreach ( (array)$this->actions as $action ) { $action->apply( $action_args ); } return self::OK; // We delay sending the OK until here incase a fatal error strikes during action processing, or client timeouts } private function extract_email($email){ $email = trim($email, '<> '); if ( strpos($email, ':') ) $email = substr( $email, strrpos($email, ':')); return $email; } private function check_stack($cmds) { foreach ( (array)$cmds as $command ) if ( ! in_array($command, $this->stack) ) return false; return true; } private function remove_from_stack($cmd) { foreach ( (array)$cmds as $command ) foreach ( $this->stack as $index => $value ) if ( $command == $value ) unset($this->stack[ $index ]); return true; } private function check_sent_stack($cmds) { foreach ( (array)$cmds as $command ) if ( ! in_array($command, $this->sent_stack) ) return false; return true; } private function accept_mail($email) { $pos = strrpos($email, '@'); $user = substr($email, 0, $pos); $domain = substr($email, $pos+1); // If we don't handle that domain.. if ( !isset($this->mailboxes[ $domain ]) ) return false; // If we handle that specific user if ( in_array($user, $this->mailboxes[ $domain ]) ) return true; // See if we have a wildcard for the domain if ( in_array('*', $this->mailboxes[$domain]) ) return true; // Seems we don't handle mail for this guy return false; } private function response($code, $data = array() ) { $data_defaults = array(); $data = array_merge($data, $data_defaults); $string = $this->response_strings($code); $string = "$code $string"; foreach ( $data as $key => $value ) $string = str_ireplace("<$key>", $value, $string); return $string; } private function response_strings($code = false) { $codes = array( 211 => 'System status, or system help reply', // 211 214 => 'Help message', // 214 self::WELCOME => ' Service ready', // 220 self::GOODBYE => ' Service closing transmission channel', // 221 self::OK => 'Requested mail action okay, completed', // 250 251 => 'User not local; will forward to ', // 251 252 => 'Cannot VRFY user, but will accept message and attempt delivery', // 252 self::DATA_AHEAD => 'Start mail input; end with .', // 354 421 => ' Service not available, closing transmission channel', // 421 self::MAILBOX_BUSY => 'Requested mail action not taken: mailbox unavailable', // 450 451 => 'Requested action aborted: local error in processing', // 451 452 => 'Requested action not taken: insufficient system storage', //452 self::SYNTAX_ERROR => 'Syntax error, command unrecognized', // 500 501 => 'Syntax error in parameters or arguments', // 501 self::NOT_IMPLEMENTED => 'Command not implemented', // 502 self::OUT_OF_ORDER => 'Bad sequence of commands', // 503 504 => 'Command parameter not implemented', // 504 self::MAILBOX_UNAVAILABLE => 'Requested action not taken: mailbox unavailable', // 550 551 => 'User not local; please try ', // 551 self::MAILBOX_FULL => 'Requested mail action aborted: exceeded storage allocation', // 552 553 => 'Requested action not taken: mailbox name not allowed', // 553 554 => 'Transaction failed', // 554 ); // Friendly $codes[self::OK] = 'OK All recieved'; $codes[self::WELCOME] = ' at your service'; $codes[self::DATA_AHEAD] = 'Send her through, end with . please'; $codes[self::NOT_IMPLEMENTED] = "Sorry ol' chap, I'm not quite sure I can do that"; $codes[self::GOODBYE] = "Cherio Mate. Closing the trnasmission channel"; if ( $code ) return $codes[$code]; else return $codes; } } class Mail_Message { var $body = ''; var $id = 0; private $original_data = ''; private $headers = array(); function __construct($data) { // Give messages a unique ID of the microtime $time = explode(' ', microtime()); $this->id = (int)$time[1] + (float)$time[0]; $this->parse_data($data); $this->original_data = $data; // We'll possibly need an untained copy of this data.. Note, it doesn't contain this hosts's Recieved: line } private function parse_data($data) { list($headers, $this->body) = preg_split("!\r?\n\r?\n!", $data, 2); // Unroll - This should really be detected properly.. and remove the header.. $this->body = preg_replace("![^=]=\r?\n!", '$1', $this->body); // So that we can // base64_decode() it! $this->body = $this->decode_quoted_printabe($this->body); // Ie. documents with =3D scattered through the HTML. //Combine headers $headers = preg_replace("!\n\s+!", " ", $headers); if ( preg_match_all('!^([a-z-]+?):\s*(.*)!im', $headers, $headers_a) ) { foreach ( $headers_a[1] as $index => $key ) { $this->add_header($key, $headers_a[2][$index]); } } } public function add_header($key, $value, $placement = 'bottom') { $value = trim($value); $this->headers[$key] = ( ! isset($this->headers[$key]) ? $value : ( 'bottom' == $placement ? array_merge( (array)$this->headers[$key], (array)$value ) : array_merge( (array)$value, (array)$this->headers[$key] ) ) ); } public function get_full_message() { $full_message = ''; foreach ( $this->headers as $header => $value ) { if ( is_array($value) ) $full_message .= "$header: " . implode("\n$header: ", $value) . "\n"; else $full_message .= "$header: $value\n"; } $full_message .= "\n\n" . $this->body; return $full_message; } public function __get($key) { // Exact match if ( isset($this->headers[$key]) ) return $this->headers[ $key ]; // Case insensitive header match $key = strtolower($key); foreach ( $this->headers as $header => $value ) if ( $key == strtolower($header) ) return $value; // Not found. return null; } private function decode_quoted_printabe($data) { return preg_replace_callback('!(=[A-Z0-9]{1,2})!', array($this, 'decode_quoted_printable_cb'), $data); } private function decode_quoted_printable_cb($match) { return chr(hexdec(trim($match[0], '='))); } } class Mail_Action { /** * I should really juse use apply_filters() / add_action() really, * I'm going to keep this stand alone for now, however am likely to * Integrate a Daemonized application (ie. not relying on xinetd) which will * use/include WordPress as the framework, so will use the WP API then. **/ var $priority = 10; function __construct( $args = array() ) { foreach ( $args as $key => $val ) $this->$key = $val; } function apply($args = array() ) { //This function applies the current action. // Step1 if ( !empty($this->conditions) ) { foreach ( (array)$this->conditions as $condition => $values ) { foreach ( (array)$values as $value ) { $op = $value[0] == '!' ? '!=' : '='; if ( '!' == $value[0] ) $value = substr($value, 1); switch ( $condition ) { case 'email': //Must match exactly. if ( '=' == $op && $value != $args['server']->mailbox ) return false; elseif ( '!=' == $op && $value == $args['server']->mailbox ) return false; break; case 'domain': //Must match exactly. if ( $value != $args['server']->domain ) return false; break; default: //echo "$condition not implemented.\n"; } } } } $result = $this->cb($args); if ( !empty($args['server']->alert_email) && !is_bool($result) && !empty($result) ) { // It's produced some data.. Err.. What do we do with it guys?! @mail( $args['server']->alert_email, '[PHP Mail Server] Filter ' . get_class($this) . ' produced unexpected output', $result ); } } /** * Sorts actions based on their priorities * Designed to be called statically, ie. Mail_Action::sort($actions); **/ static function sort(&$actions) { usort($actions, array('Mail_Action', 'sort_cb')); return $actions; // Return them anyway just in case. } private function sort_cb($a, $b) { return $a->priority > $b->priority; } } // Logs to files class Mail_Action_Log extends Mail_Action { function cb($args) { if ( empty($this->directory) ) return 'Log directory not specified.'; if ( !is_writable($this->directory) ) return sprintf( 'Log directory (%s) not writable.', $this->dif); $server = $args['server']; $mail = $args['mail']; $filename = rtrim($this->directory, '/') . '/'; // Sort it into directories? if ( !empty($this->{'email-directories'}) ) $filename .= $args['server']->mailbox . '/'; // Make the directory if it doesn't exist. if ( ! is_dir($filename) ) @mkdir($filename); // Filename starts with the message ID $filename .= $mail->id; // If we want to include the subject in the email log name if ( !empty($this->{'subject-in-filename'}) ) { $subject = preg_replace('!-+!', '-', preg_replace('![^\[\]a-z0-9-.]!i', '-', $mail->Subject)); $filename .= '-' . substr($subject, 0, 50); } // And ends with a .txt $filename .= '.txt'; return (bool)file_put_contents($filename, $mail->get_full_message()); } } // Executes a process class Mail_Action_Execute extends Mail_Action { function cb( $args = array() ) { return shell_exec( $this->command ); } } //Forwards an email class Mail_Action_Forward extends Mail_Action { function cb( $args = array() ) { if ( empty($this->destination) ) return 'Destination email address not specified!'; $mail = $args['mail']; $subject = $mail->Subject; $from = $mail->From; $to = $mail->To; $time = $mail->Date; $body = " ---------- Forwarded message ---------- From: $from Date: $time Subject: $subject To: $to "; $body .= $mail->get_full_message(); $body .= var_export( array('env' => $_ENV, 'server' => $_SERVER), true); return @mail($this->destination, 'Fwd: ' . $subject, $body); } } $config = array( // an open stream to receive data on 'in' => STDIN, // an open stream to respond to the client 'out' => STDOUT, // The length of time an individiual connection can run for, in seconds, defaults to forever 'timeout' => 0, // The remote address of the client 'remote_addr' => isset($_SERVER['REMOTE_HOST']) ? $_SERVER['REMOTE_HOST'] : '127.255.255.255', // 127. is just a dummy address to distinguish it from localhost.. // The email addresses which we're to respond to 'mailboxes' => array(), // Actions to apply on recieved emails matched to above mailboxes 'actions' => array(), // The email to alert to problems (output from actions) 'alert_email' => false ); // Plugins will be automatically loaded from /mail-plugins/*.php, see the included 8ball example if ( is_dir( dirname(__FILE__) . '/mail-plugins/') ) { $plugins = glob( dirname(__FILE__) . '/mail-plugins/*.php'); foreach ( (array)$plugins as $plugin ) include_once($plugin); } // Include the config if it's been specified if ( file_exists( dirname(__FILE__) . '/mail-config.php') ) require( dirname(__FILE__) . '/mail-config.php' ); $server = new Mail_Server($config);