Page 3 of 4

Posted: Tue May 02, 2006 12:02 am
by John Cartwright
That's enough debating on naming conventions.

Posted: Fri May 05, 2006 3:26 am
by Chris Corbyn
I'm releasing this soon, but I have a quick question... apologies, this is more of a PHP Code questionn than a theory one. If I want to use an SSL connection to the server is it really just as easy as:

fsockopen('ssl://smtp.server.com', $port)

in place of

fsockopen('smtp.server.com', $port)

?

That seems too easy :?

Here's the code at present of anybody wants a gander.

Supports:
  • Batch Emailing via a single connection
  • Multipart/MIME Version 1.0 type mail
  • Attachments passed as string data so can link with GD/LibPDF etc.
  • Custom Headers
  • SMTP Authentication (PLAIN, LOGIN, CRAM-MD5)
  • Custom SMTP commands
  • Sendmail support
  • Ability to write your own custom connection classes for unusual scenarios
  • No need to use the mail() function
Main mailer class:

Code: Select all

<?php

/*
 
 dMail: A Flexible PHP Mailer Class.
 Version: 0.0.0
 Author: Chris Corbyn
 Date: 1st May 2006
 
 Current functionality:
  
  * Send uses one single connection to the SMTP server
  * Doesn't rely on mail()
  * Custom Headers
  * Sends Multipart messages, handles encoding
  * Sends Plain-text single-part emails
  * Batch emailing
  * Support for multiple attachments
  * Sendmail (or other binary) support
  * SMTP Authentication (LOGIN, PLAIN, MD5-CRAM)
 
 Things to add:
 
  * SSL (Not sure?)
 
 -----------------------------------------------------------------------

    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 2 of the License, or
    any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program; if not, write to the Free Software
    Foundation, Inc.,
    51 Franklin Street,
    Fifth Floor,
    Boston,
    MA  02110-1301  USA
    
    "Chris Corbyn" <chris@w3style.co.uk>

 */

class mailer
{
	private
	
	//Standard properties
	$mailObject,
	$esmtp = false,
	$authTypes = array(),
	// * Hey this library is FREE so it's not much to ask   But if you really do want to
	// remove this header then go ahead of course... what's GPL for? 
	$headers = "X-Mailer: dMail by Chris Corbyn\r\n",
	$domain = 'dMailUser',
	//MIME support
	$mimeBoundary,
	$mimeWarning,
	$parts = array(),
	$attachments = array(),
	//Used internally to check the status
	$responseCode,
	$expectedCodes = array(
		'ehlo' => 250,
		'helo' => 250,
		'auth' => 334,
		'mail' => 250,
		'rcpt' => 250,
		'data' => 354
	),
	$failed = false;
	
	public
	
	//Just logging stuff
	$errors = array(),
	$transactions = array(),
	$lastResponse;
	
	public function __construct($object, $domain)
	{
		$this->domain = $domain;
		$this->mailObject =& $object;
		
		if (!$this->mailObject->start())
		{
			$this->logError('Unable to open a connection in the mail object', 0);
		}
		else
		{
			//What did the server greet us with on connect?
			$this->logTransaction();
			if ($this->supportsESMTP($this->lastResponse))
			{
				//Just being polite
				$list = $this->command("EHLO {$this->domain}\r\n");
				$this->getAuthenticationMethods($list);
				
				$this->esmtp = true;
			}
			else $this->command("HELO {$this->domain}\r\n");
		}
		
		$this->mimeWarning = "This part of the E-mail should never be seen. If\r\n".
		"you are reading this, consider upgrading your e-mail\r\n".
		"client to a MIME-compatible client.";
	}

	private function supportsESMTP($greeting)
	{
		//Not mentiioned in RFC 2821 but this how it's done
		if (preg_match('/\bESMTP\b/', $greeting)) return true;
		else return false;
	}

	//Looks for a line that says the server supports AUTH and
	// then checks the types it allows
	private function getAuthenticationMethods($list)
	{
		preg_match("/^250[\-\ ]AUTH\ (.*)\r\n/m", $list, $matches);
		if (!empty($matches[1]))
		{
			$types = explode(' ', $matches[1]);
			$this->authTypes = $types;
		}
	}

	//AUTHENTICATION MECHANISMS BELOW (See RFC 2554)
	public function authenticate($username, $password=null)
	{
		if (!$this->esmtp || empty($this->authTypes)) return false;
		
		if (in_array('LOGIN', $this->authTypes)) return $this->authLOGIN($username, $password);
		elseif (in_array('CRAM-MD5', $this->authTypes)) return $this->authCRAM_MD5($username, $password);
		elseif (in_array("PLAIN", $this->authTypes)) return $this->authPLAIN($username, $password);
		else
		{
			$this->logError('The server doesn\'t like any of dMail\'s implemented authentication mechanisms', 0);
			$this->failed = true;
			return false;
		}
	}

	//Authenticate using LOGIN (base64 encoded speaking here)
	private function authLOGIN($username, $password)
	{
		$response = $this->command("AUTH LOGIN\r\n");
		//This should be the server OK go ahead and give me a username
		preg_match('/^334\ (.*)$/', $response, $matches);
		if (!empty($matches[1]))
		{
			$decoded_response = base64_decode($matches[1]);
			if (strtolower($decoded_response) == 'username:')
			{
				$response = $this->command(base64_encode($username));
				//This should be the server saying now give me a password
				preg_match('/^334\b\ (.*)$/', $response, $matches);
				if (!empty($matches[1]))
				{
					$decoded_response = base64_decode($matches[1]);
					if (strtolower($decoded_response) == 'password:')
					{
						//235 is a good authentication response!
						$this->command(base64_encode($password));
						if ($this->responseCode == 235) return true;
					}
				}
			}
		}
		//If the logic got down here then the authentication failed
		$this->logError('Authentication failed using LOGIN', $this->responseCode);
		$this->failed = true;
		return false;
	}

	private function authPLAIN($username, $password)
	{
		//The authorization string uses ascii null as a separator (See RFC 2554)
		$auth_string = base64_encode("$username\0$username\0$password");
		$this->command("AUTH PLAIN\r\n");
		if ($this->responseCode == 334)
		{
			$this->command("$auth_string\r\n");
			//This should be the server saying OK
			if ($this->responseCode == 235)
			{
				return true;
			}
		}
		$this->logError('Authentication failed using PLAIN', $this->responseCode);
		$this->failed = true;
		return false;
	}

	private function authCRAM_MD5($username, $password)
	{
		$response = $this->command("AUTH CRAM-MD5\r\n");
		preg_match('/^334\ (.*)$/', $response, $matches);
		if (!empty($matches[1]))
		{
			//This response is a base64 encoded challenge "<123456.123456789@domain.tld>"
			$decoded_response = base64_decode($matches[1]);
			
			//We need to generate a digest using this challenge
			$digest = $username.' '.$this->_authGenerateCRAM_MD5_Response($password, $decoded_response);
			//We then send the username and digest as a base64 encoded string
			$auth_string = base64_encode($digest);
			$this->command("$auth_string\r\n");
			
			if ($this->responseCode == 235) //235 means OK
			{
				return true;
			}
		}
		$this->logError('Authentication failed using CRAM-MD5', $this->responseCode);
		$this->failed = true;
		return false;
	}
	
	//This has been lifted from a PEAR implementation at
	// http://pear.php.net/package/Auth_SASL/
	private function _authGenerateCRAM_MD5_Response($password, $challenge)
	{
		if (strlen($password) > 64)
			$password = pack('H32', md5($password));

		if (strlen($password) < 64)
			$password = str_pad($password, 64, chr(0));

		$k_ipad = substr($password, 0, 64) ^ str_repeat(chr(0x36), 64);
		$k_opad = substr($password, 0, 64) ^ str_repeat(chr(0x5C), 64);

		$inner  = pack('H32', md5($k_ipad.$challenge));
		$digest = md5($k_opad.$inner);

		return $digest;
	}
	//END AUTHENTICATION

	//Each multipart section of an email needs a unique boundary
	// This should get one for us
	private function getMimeBoundary($string=false)
	{
		$force = true;
		
		if (!$string)
		{
			$force = false;
			$string = implode('', $this->parts);
			$string .= implode('', $this->attachments);
		}
		
		if ($this->mimeBoundary && !$force) return $this->mimeBoundary;
		else
		{ //Make sure we don't (as if it would ever happen!) -
		  // produce a hash that's actually in the email already
			do
			{
				$this->mimeBoundary = 'dMail-'.strtoupper(md5($string));
			} while(strpos($string, $this->mimeBoundary));
		}
		return $this->mimeBoundary;
	}

	public function addHeaders($string)
	{
		$this->headers .= $string;
		if (substr($this->headers, -2) != "\r\n")
			$this->headers .= "\r\n";
	}

	public function setMimeBoundary($string)
	{
		$this->mimeBoundary = $string;
	}

	public function setMimeWarning($warning)
	{
		$this->mimeWarning = $warning;
	}

	//When a multipart message has been sent, and the user wants to
	// send a different message through the same connection flush() needs
	// to be called to clear the mime parts
	public function flush($clear_headers=false)
	{
		$this->parts = array();
		$this->attachments = array();
		$this->mimeBoundary = null;
		//See comment above the headers property above the constructor before editing this line! *
		if ($clear_headers) $this->headers = "X-Mailer: dMail by Chris Corbyn\r\n";
	}

	//Dish out the appropriate commands to send an email through the server
	public function send($to, $from, $subject, $body=false, $type='text/plain', $encoding='7bit')
	{
		if (!is_array($to)) $to = array($to);
		
		$get_body = true;
		$cached_body = '';
		foreach ($to as $address)
		{
			//** I need to work on a cache here **
			$data = $this->buildMail($address, $from, $subject, $body, $type, $encoding, $loop);
			echo count($data).'**';
			if (!$get_body && !empty($cached_body))
			{
				$data[] = $this->makeRecipientHeaders($address).$cached_body; //Since one wasn't returned
			}
			foreach ($data as $command)
			{
				if ($get_body && $this->responseCode == 354) //This means we're about to send the DATA part
				{
					$cached_body = $command;
					$command = $this->makeRecipientHeaders($address).$command;
				}
				if (!$this->command($command)) return false;
			}
			$get_body = false;
		}
		$this->flush(true);
		
		return true;
	}

	//Connect errors, timeouts, anything that went wrong but
	// wasn't brought up by SMTP itself
	private function logError($errstr, $errno=0)
	{
		$this->errors[] = array(
			'num' => $errno,
			'time' => microtime(),
			'message' => $errstr
		);
	}

	//Keep track of everything that gets said
	public function logTransaction($command='')
	{
		$this->transactions[] = array(
			'command' => $command,
			'time' => microtime(),
			'response' => $this->getResponse()
		);
	}

	//Read back the data from our socket
	private function getResponse()
	{
		if (!$this->mailObject->readHook) return false;
		
		$ret = "";
		while (true)
		{
			$tmp = fgets($this->mailObject->readHook);
			//The last line of SMTP replies have a space after the status number
			// They do NOT have an EOF so while(!feof($socket)) will hang!
			if (!preg_match('/^\d+\ /', $tmp)) $ret .= $tmp;
			else
			{
				$ret .= $tmp;
				break;
			}
		}
		
		$this->responseCode = $this->getResponseCode($ret);
		$this->lastResponse = $ret;
		return $ret;
	}

	private function getResponseCode($string)
	{
		if (preg_match('/^\d+/', $string, $matches))
		{
			return (int) $matches[0];
		}
		else return 0;
	}

	private function getCommandKeyword($comm)
	{
		if (preg_match('/^\s*([\S]+)/', $comm, $matches))
		{
			if (!empty($matches[1])) return strtolower($matches[1]);
			else return '';
		}
		else return '';
	}

	public function command($comm)
	{	
		if (!$this->mailObject->writeHook || $this->failed) return false;

		$command_keyword = $this->getCommandKeyword($comm);
		
		//SMTP commands must end with CRLF
		if (substr($comm, -2) != "\r\n") $comm .= "\r\n";
		
		if (@fwrite($this->mailObject->writeHook, $comm))
		{
			$this->logTransaction($comm);
			if (array_key_exists($command_keyword, $this->expectedCodes))
			{
				if ($this->expectedCodes[$command_keyword] != $this->responseCode)
				{
					$this->failed = true;
					$this->logError($this->lastResponse, $this->responseCode);
					return false;
				}
			}
			return $this->lastResponse;
		}
		else return false;
	}

	//Add a part to a multipart message
	public function addPart($string, $type='text/plain', $encoding='7bit')
	{
		$ret = "Content-Type: $type\r\n".
				"Content-Transfer-Encoding: $encoding\r\n\r\n".
				$this->encode($string, $encoding);
		$this->parts[] = $ret;
	}

	//Attachments are added as base64 encoded data
	public function addAttachment($data, $filename, $type)
	{
		$ret = "Content-Type: $type; ".
				"name=\"$filename\";\r\n".
				"Content-Transfer-Encoding: base64\r\n".
				"Content-Disposition: attachment;\r\n\r\n".
				chunk_split($this->encode($data, 'base64'));
		$this->attachments[] = $ret;
	}

	public function close()
	{
		if ($this->mailObject->writeHook)
		{
			$this->command("quit\r\n");
			$this->mailObject->stop();
		}
	}

	public function hasFailed()
	{
		return $this->failed;
	}

	private function encode($string, $type)
	{
		$type = strtolower($type);
		
		switch ($type)
		{
			case 'base64':
			$string = base64_encode($string);
			break;
			//
			case 'quoted-printable':
			$string = $this->quotedPrintableEncode($string);
			//
			case '7bit':
			default:
			break;
		}
		
		return $string;
	}

	//From php.net by user bendi at interia dot pl
	private function quotedPrintableEncode($string)
	{
		$string = preg_replace('/[^\x21-\x3C\x3E-\x7E\x09\x20]/e', 'sprintf( "=%02x", ord ( "$0" ) ) ;', $string);
		preg_match_all('/.{1,73}([^=]{0,3})?/', $string, $matches);
		return implode("=\r\n", $matches[0]);
	}

	//Because SMTP data transmissions end with CRLF <dot> CRLF
	// we need to remove any of these from the string.
	private function escapeDot($string)
	{
		$stack = array();
		$lines = explode("\r\n", $string);
		foreach ($lines as $l)
		{
			if ($l == '.') $l = ' .'; //Quite simple... just add some whitespace padding!
			$stack[] = $l;
		}
		return implode("\r\n", $stack);
	}

	private function makeRecipientHeaders($address)
	{
		return "To: $address\r\n";
	}

	//Builds the list of commands to send the email
	private function buildMail($to, $from, $subject, $body, $type='text/plain', $encoding='7bit', $return_data_part=true)
	{
		$date = date('r'); //RFC 2822 date

		if ($return_data_part)
		{
			if ($body) //Ignore multipart messaging
			{
				$data = "From: $from\r\n".
					"Reply-To: $from\r\n".
					"Subject: $subject\r\n".
					"Date: $date\r\n".
					"{$this->headers}".
					"Content-Type: $type\r\n".
					"Content-Transfer-Encoding: $encoding\r\n\r\n".
					$this->escapeDot($this->encode($body, $encoding));
			}
			else //Build a multipart message
			{
				$boundary = $this->getMimeBoundary(); //Overall MIME boundary
				
				$message_body = implode("\r\n\r\n--$boundary\r\n", $this->parts);
	
				if (!empty($this->attachments)) //Make a sub-message that contains attachment data
				{
					$attachment_boundary = $this->getMimeBoundary(implode('', $this->attachments));
					
					$attachments = implode("\r\n\r\n--$attachment_boundary\r\n", $this->attachments);
					
					$attachments = "\r\n\r\n--$boundary\r\n".
					"Content-Type: multipart/alternative;\r\n".
					"	boundary=\"$attachment_boundary\"\r\n".
					"Content-Transfer-Encoding: 7bit\r\n\r\n".
					"\r\n\r\n--$attachment_boundary\r\n".
					$attachments.
					"\r\n--$attachment_boundary--\r\n";
				}
				
				$data = "From: $from\r\n".
					"Reply-To: $from\r\n".
					"Subject: $subject\r\n".
					"Date: $date\r\n".
					"{$this->headers}".
					"MIME-Version: 1.0\r\n".
					"Content-Type: multipart/mixed;\r\n".
					"	boundary=\"{$boundary}\"\r\n".
					"Content-Transfer-Encoding: 7bit\r\n\r\n";
	
				$data .= $this->mimeWarning;
				
				if (isset($attachments)) $message_body .= $attachments;
				
				$data .= "\r\n\r\n--$boundary\r\n".
					"$message_body\r\n".
					"\r\n--$boundary--";
			}
	
			$data = $this->escapeDot($data);
	
			$data .= "\r\n.\r\n";
		}
		
		$ret = array(
			//Can be spoofed but that's not my problem
			"mail from: $from\r\n",
			//Recipient
			"rcpt to: $to\r\n",
			//Inform that we're about the give our message
			"data\r\n"
		);
		
		if ($return_data_part) $ret[] = $data;

		return $ret;
	}
}

?>
The connection classes haven't changed since the last post... the mailer class now also caches the email in a local variable when sending to many addresses, thus saving time on encoding.

I was looking over the docs at phpMailer and it seems that almost everything that does, mine does with less lines of code. They do have a few more encoding types though but those can be easily added.

Posted: Fri May 05, 2006 5:35 am
by Chris Corbyn
d11wtq wrote:I'm releasing this soon, but I have a quick question... apologies, this is more of a PHP Code questionn than a theory one. If I want to use an SSL connection to the server is it really just as easy as:

fsockopen('ssl://smtp.server.com', $port)

in place of

fsockopen('smtp.server.com', $port)

?

That seems too easy :?
OK I answered my own question... turns out it really is that simple :) I just hadn't compiled OpenSSH support into PHP.

Posted: Fri May 05, 2006 2:41 pm
by Christopher
Looks great. Is there any reason why the class could not be made to work in PHP4 other than public/private and __construct?

Posted: Fri May 05, 2006 3:17 pm
by Chris Corbyn
arborint wrote:Looks great. Is there any reason why the class could not be made to work in PHP4 other than public/private and __construct?
Nothing that I can think of... I just personally dislike not using the appropriate keywords when they're available... I'll run two concurrent versions for downloading.

Posted: Sat May 06, 2006 7:05 am
by Chris Corbyn
OK now I'm thinking about optional extensions and/or plugins to include. Some have been mentioned:

A templating engine
A logger (to DB, XML, Text file etc)

What else? There's something I did think of:

A SPAM checker -- Not for blocking SPAM of course, but something that runs spamassassin over it and reports if the mail will likely be caught as junk.

If this had plugin support I'm sure there most be loads of things people could think of to write.

Posted: Sat May 06, 2006 7:22 am
by feyd
  • Pluggable authentication handling
  • Not just a template engine, but a pluggable one to allow custom tags and things for whatever reason.

Posted: Sun May 07, 2006 12:28 pm
by Christopher
I think the auth code could be made pluggable in a similar way to the connections.

For things like templating, perhaps a simple Filter Chain that allowed you to post process the email would be the way to go. But would it have access to an array of parts or the whole finished email? I think the former and the mailer would then assemble everything.

And I think if you added command() method to the connections you would get all low level I/O out of the mailer class.

Posted: Sun May 07, 2006 4:58 pm
by Chris Corbyn
I guess for the authentication stuff you could do something like:

Code: Select all

$mailer->loadAuthenticator(new loginAuthenticator, 'LOGIN');
$mailer->loadAuthenticator(new cramMD5Authenticator, 'CRAM-MD5');
$mailer->loadAuthenticator(new plainAuthenticator, 'PLAIN');

$mailer->authenticate('username', 'password');
But I'm concious of increasing the complexity of the interface too much, so that less-savvy developers will avoid using this class. Something like authentication is almost certainly required/expected if it's going to be used on SMTP servers... I could have the class automatically load in the default authenticators (PLAIN, LOGIN, CRAM-MD5) but then provide something like I've shown above to provide a way to load in some others (KERBEROS, DIGEST-MD5...). The idea is that the mailer looks to see what methods the SMTP server supports and goes through the one we have implemented to see if they are compatible.

Perhaps like having the connection classes with a couple of methods:

Code: Select all

public function setCommandCallback(&$obj, $function)
{
    $this->baseObject = $obj;
    $this->commandFunction = $function;
}

//Yuck!
public function command($comm)
{
    return $this->baseObject->{$this->commandFunction}($comm);
}
The mailer class could then simply provide a means to access it's own command() method as soon as the connection class gets loaded.... I assume the reason this would be useful is in situations where a certain connection needs some additional set up by speaking with the SMTP server.

I haven't really thought too deeply into how a template system would work but it would probably be a good idea to make everything pluggable in any case... with some sort of well-handled plugin interface the flexibility would be massive.

Posted: Sun May 07, 2006 6:26 pm
by Chris Corbyn
I've updated the code to make the authenticators pluggable:

Code: Select all

$mailer->loadAuthenticator(new LOGIN_authenticator);
$mailer->authenticate('username', 'pasword');
The loader looks like this:

Code: Select all

public function authenticate($username, $password=null)
	{
		if (!$this->esmtp || empty($this->authTypes)) return false;
		
		foreach ($this->authenticators as $name => $object)
		{
			if (in_array($name, $this->authTypes))
			{
				return $this->authenticators[$name]->run($username, $password);
			}
		}
		
		//If we get this far, no authenticators were used
		$this->logError('The server doesn\'t like any of dMail\'s implemented authentication mechanisms', 0);
		$this->failed = true;
		return false;
	}

	public function loadAuthenticator(&$object)
	{
		$this->authenticators[$object->serverString] =& $object;
		$this->authenticators[$object->serverString]->loadBaseObject(&$this);
	}
With an authenticator class looking like this:

Code: Select all

class LOGIN_authenticator
{
	public $serverString = 'LOGIN';
	private $baseObject;

	public function __construct()
	{
		//
	}

	public function loadBaseObject(&$object)
	{
		$this->baseObject =& $object;
	}

	public function run($username, $password)
	{
		return $this->authLOGIN($username, $password);
	}

	private function authLOGIN($username, $password)
	{
		$response = $this->baseObject->command("AUTH LOGIN\r\n");
		//This should be the server OK go ahead and give me a username
		preg_match('/^334\ (.*)$/', $response, $matches);
		if (!empty($matches[1]))
		{
			$decoded_response = base64_decode($matches[1]);
			if (strtolower($decoded_response) == 'username:')
			{
				$response = $this->baseObject->command(base64_encode($username));
				//This should be the server saying now give me a password
				preg_match('/^334\b\ (.*)$/', $response, $matches);
				if (!empty($matches[1]))
				{
					$decoded_response = base64_decode($matches[1]);
					if (strtolower($decoded_response) == 'password:')
					{
						//235 is a good authentication response!
						$this->baseObject->command(base64_encode($password));
						if ($this->baseObject->responseCode == 235) return true;
					}
				}
			}
		}
		//If the logic got down here then the authentication failed
		$this->baseObject->logError('Authentication failed using LOGIN', $this->responseCode);
		$this->baseObject->failed = true;
		return false;
	}
}

?>
The authenticator class MUST provide a public property "serverString" which identifies what the server would call this method, it must contain a run() method to execute the authentication and also a loadBaseObject() method to provide access to the sockets and the command() method.

I'm wondering... because this basically induces this sort of a pattern:

Code: Select all

class baseClass
{
    private $innerObj;
    
    function loadObject(&$object)
    {
        $this->innerObj =& $object;
        $this->innerObj->loadObject(&$this);
    }
}

class innerClass
{
    private $baseObj;
    
    function loadObject(&$object)
    {
        $this->baseObj =& $obj;
    }
}

$object = new baseClass;
$object->loadObject(new innerClass);
There's actually recursion there where each class refers back to itself via the other class in a never-ending fashion... is this bad?? It works though.

Posted: Sun May 07, 2006 10:29 pm
by Christopher
I am wondering if this might be better?

Code: Select all

$mailer= new Mailer(new SmtpConnection());
$authenticator = new LOGIN_authenticator($mailer);
$authenticator->authenticate('username', 'pasword');

Posted: Mon May 08, 2006 1:25 am
by Chris Corbyn
arborint wrote:I am wondering if this might be better?

Code: Select all

$mailer= new Mailer(new SmtpConnection());
$authenticator = new LOGIN_authenticator($mailer);
$authenticator->authenticate('username', 'pasword');
Yes it looks better logically but then you're expecting your users to know what authentication mechanism to use... the other way the mailer class does it for you. i.e. Would you know whether smtp.ntlworld.com needed LOGIN, PLAIN or CRAM-MD5 ?

Perhaps something along those lines where the authenticator contains a container like I did with the mailer... so we can then load in as many as we need.

Thanks for the input :)

Posted: Mon May 08, 2006 3:58 am
by Christopher
It seems like if you didn't know you would try them until one worked. For searching for the right type, maybe just create a helper function like:

Code: Select all

$mailer= new Mailer(new SmtpConnection());
$authenticator = authenticator_type_search($mailer, array('LOGIN', 'PLAIN', 'CRAM-MD5'));
$authenticator->authenticate('username', 'pasword');

Posted: Wed May 10, 2006 2:33 pm
by Chris Corbyn
arborint wrote:It seems like if you didn't know you would try them until one worked. For searching for the right type, maybe just create a helper function like:

Code: Select all

$mailer= new Mailer(new SmtpConnection());
$authenticator = authenticator_type_search($mailer, array('LOGIN', 'PLAIN', 'CRAM-MD5'));
$authenticator->authenticate('username', 'pasword');
Sorry I didn't really like that idea of having yet another function for the user to learn how to use :oops: I've added an autoloader in the class which looks for a directory (PEAR standard naming now) of the same name as the class and the scans for authenticator classes if none have been loaded in at the time authenticate() is called.

Moving on however, a plugin system has been put in place but I'm wondering if I've perhaps gone about it in a roundabout way.

What I've done, is implemented the plugin loader much like how the authenticators are loaded (they get passed an a reference to $this). I've then moved a few things out into public properties (such as the current mail that being sent) so that they can be interacted with. (Now I think this is quite nice and clever :P) I've also added some event handlers. Obviosuly this isn't javascript and these aren't proper event handlers but basically, at certain points during execution inside the mailer, methods adhering to a certain API or then executed in the plugins where possible. i.e. onSend(), and onError() etc etc. I felt that this gives plugin authors a lot of information about what's going on the class and allows a more fine grained interaction?

My main question is about the event handlers... does it sound like a good idea or just me getting over-excited?

Code: Select all

//This is called at relevant steps in the mailer i.e. in send(), $this->runPluginMethods('onSend');

	private function runPluginMethods($func)
	{
		foreach ($this->plugins as $name => $object)
		{
			if (method_exists($this->plugins[$name], $func))
			{
				$this->plugins[$name]->$func();
			}
		}
	}

Posted: Wed May 10, 2006 2:38 pm
by Chris Corbyn
Regarding my post above, here's the full class (notice the calls to various event handlers throught?)

NOTE: I had to change the name of the class since dMail is actually an MTA which was confusing.

Code: Select all

<?php

/*
 
 Swift Mailer: A Flexible PHP Mailer Class.
 Version: 0.0.1
 Author: Chris Corbyn
 Date: 10th May 2006

 (C) Copyright 2006 Chris Corbyn - All Rights Reserved.
 
 Current functionality:
  
  * Send uses one single connection to the SMTP server
  * Doesn't rely on mail()
  * Custom Headers
  * Sends Multipart messages, handles encoding
  * Sends Plain-text single-part emails
  * Batch emailing
  * Support for multiple attachments
  * Sendmail (or other binary) support
  * Pluggable SMTP Authentication (LOGIN, PLAIN, MD5-CRAM)
  * Secure Socket Layer connections (SSL)
  * Loadable plugin support with event handling features
 
 -----------------------------------------------------------------------

    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 2 of the License, or
    any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program; if not, write to the Free Software
    Foundation, Inc.,
    51 Franklin Street,
    Fifth Floor,
    Boston,
    MA  02110-1301  USA
    
    "Chris Corbyn" <chris@w3style.co.uk>

 */

class Swift
{
	private
	
	//Standard properties
	$connection,
	$authenticators = array(),
	$plugins = array(),
	$esmtp = false,
	$authTypes = array(),
	$domain = 'SwiftUser',
	//MIME support
	$mimeBoundary,
	$mimeWarning,
	$parts = array(),
	$attachments = array(),
	//Used internally to check the status
	$expectedCodes = array(
		'ehlo' => 250,
		'helo' => 250,
		'auth' => 334,
		'mail' => 250,
		'rcpt' => 250,
		'data' => 354
	);
	
	public
	
	$failed = false,
	$responseCode,
	
	//For plugins (& used internally)
	$currentMail = array(),
	// * Hey this library is FREE so it's not much to ask   But if you really do want to
	// remove this header then go ahead of course... what's GPL for? 
	$headers = "X-Mailer: Swift by Chris Corbyn\r\n",
	$currentCommand = '',
	
	//Just logging stuff
	$errors = array(),
	$transactions = array(),
	$lastTransaction,
	$lastError,
	$lastResponse;
	
	public function __construct($object, $domain)
	{
		$this->domain = $domain;
		$this->connection =& $object;
		
		if (!$this->connection->start())
		{
			$this->failed = true;
			$this->logError('Unable to open a connection in the mail object', 0);
		}
		else
		{
			//What did the server greet us with on connect?
			$this->logTransaction();
			if ($this->supportsESMTP($this->lastResponse))
			{
				//Just being polite
				$list = $this->command("EHLO {$this->domain}\r\n");
				$this->getAuthenticationMethods($list);
				
				$this->esmtp = true;
			}
			else $this->command("HELO {$this->domain}\r\n");
		}
		
		$this->mimeWarning = "This part of the E-mail should never be seen. If\r\n".
		"you are reading this, consider upgrading your e-mail\r\n".
		"client to a MIME-compatible client.";
	}

	private function supportsESMTP($greeting)
	{
		//Not mentiioned in RFC 2821 but this how it's done
		if (preg_match('/\bESMTP\b/', $greeting)) return true;
		else return false;
	}

	public function addExpectedCode($command, $code)
	{
		$this->expectedCodes[$command] = (int) $code;
	}

	//Looks for a line that says the server supports AUTH and
	// then checks the types it allows
	private function getAuthenticationMethods($list)
	{
		preg_match("/^250[\-\ ]AUTH\ (.*)\r\n/m", $list, $matches);
		if (!empty($matches[1]))
		{
			$types = explode(' ', $matches[1]);
			$this->authTypes = $types;
		}
	}

	public function loadPlugin(&$object)
	{
		$this->plugins[$object->pluginName] =& $object;
		$this->plugins[$object->pluginName]->loadBaseObject(&$this);

		if (method_exists($this->plugins[$object->pluginName], 'onLoad'))
		{
			$this->plugins[$object->pluginName]->onLoad();
		}
	}
	
	public function &getPlugin($name)
	{
		if (isset($this->plugins[$name]))
		{
			return $this->plugins[$name];
		}
	}

	private function runPluginMethods($func)
	{
		foreach ($this->plugins as $name => $object)
		{
			if (method_exists($this->plugins[$name], $func))
			{
				$this->plugins[$name]->$func();
			}
		}
	}

	//AUTHENTICATION MECHANISMS BELOW (See RFC 2554)
	private function loadDefaultAuthenticators()
	{
		$dir = dirname(__FILE__).'/Swift';
		
		if (file_exists($dir) && is_dir($dir))
		{
			$handle = opendir($dir);
			while ($file = readdir($handle))
			{
				if (preg_match('@^(Swift_\w*?_Authenticator)\.php$@', $file, $matches))
				{
					require_once($dir.'/'.$file);
					$class = $matches[1];
					$this->loadAuthenticator(new $class);
				}
			}
			closedir($handle);
		}
	}
	
	public function authenticate($username, $password=null)
	{
		if (empty($this->authenticators)) $this->loadDefaultAuthenticators();
		
		if (!$this->esmtp || empty($this->authTypes)) return false;
		
		foreach ($this->authenticators as $name => $object)
		{
			if (in_array($name, $this->authTypes))
			{
				if ($this->authenticators[$name]->run($username, $password))
				{
					$this->runPluginMethods('onAuthenticate');
					return true;
				}
				else return false;
			}
		}
		
		//If we get this far, no authenticators were used
		$this->logError('The server doesn\'t like any of Swift\'s implemented authentication mechanisms', 0);
		$this->failed = true;
		return false;
	}

	public function loadAuthenticator(&$object)
	{
		$this->authenticators[$object->serverString] =& $object;
		$this->authenticators[$object->serverString]->loadBaseObject(&$this);
	}
	//END AUTHENTICATION

	//Each multipart section of an email needs a unique boundary
	// This should get one for us
	private function getMimeBoundary($string=false)
	{
		$force = true;
		
		if (!$string)
		{
			$force = false;
			$string = implode('', $this->parts);
			$string .= implode('', $this->attachments);
		}
		
		if ($this->mimeBoundary && !$force) return $this->mimeBoundary;
		else
		{ //Make sure we don't (as if it would ever happen!) -
		  // produce a hash that's actually in the email already
			do
			{
				$this->mimeBoundary = 'swift-'.strtoupper(md5($string.microtime()));
			} while(strpos($string, $this->mimeBoundary));
		}
		return $this->mimeBoundary;
	}

	public function addHeaders($string)
	{
		$this->headers .= $string;
		if (substr($this->headers, -2) != "\r\n")
			$this->headers .= "\r\n";
	}

	public function setMimeBoundary($string)
	{
		$this->mimeBoundary = $string;
	}

	public function setMimeWarning($warning)
	{
		$this->mimeWarning = $warning;
	}

	//When a multipart message has been sent, and the user wants to
	// send a different message through the same connection flush() needs
	// to be called to clear the mime parts
	public function flush($clear_headers=false)
	{
		$this->runPluginMethods('onFlush');
		$this->parts = array();
		$this->attachments = array();
		$this->mimeBoundary = null;
		//See comment above the headers property above the constructor before editing this line! *
		if ($clear_headers) $this->headers = "X-Mailer: Swift by Chris Corbyn\r\n";
	}

	//Dish out the appropriate commands to send an email through the server
	public function send($to, $from, $subject, $body=false, $type='text/plain', $encoding='7bit')
	{
		if (!is_array($to)) $to = array($to);
		
		$get_body = true;
		$cached_body = '';
		foreach ($to as $address)
		{
			$this->currentMail = $this->buildMail($address, $from, $subject, $body, $type, $encoding, $get_body);
			
			if (!$get_body && !empty($cached_body))
			{
				$this->currentMail[] = $this->makeRecipientHeaders($address).$cached_body; //Since one wasn't returned
			}
			
			$this->runPluginMethods('onBeforeSend');
			
			foreach ($this->currentMail as $command)
			{
				if ($get_body && $this->responseCode == 354) //This means we're about to send the DATA part
				{
					$cached_body = $command;
					$command = $this->makeRecipientHeaders($address).$command;
				}
				if (!$this->command($command)) return false;
			}
			$get_body = false;
			$this->runPluginMethods('onSend');
		}
		$this->flush(true);
		
		return true;
	}

	//Connect errors, timeouts, anything that went wrong but
	// wasn't brought up by SMTP itself
	public function logError($errstr, $errno=0)
	{
		$this->errors[] = array(
			'num' => $errno,
			'time' => microtime(),
			'message' => $errstr
		);
	}

	//Keep track of everything that gets said
	public function logTransaction($command='')
	{
		$this->lastTransaction = array(
			'command' => $command,
			'time' => microtime(),
			'response' => $this->getResponse()
		);
		$this->runPluginMethods('onLog');
		$this->transactions[] = $this->lastTransaction;
	}

	//Read back the data from our socket
	private function getResponse()
	{
		if (!$this->connection->readHook) return false;
		
		$ret = "";
		while (true)
		{
			$tmp = fgets($this->connection->readHook);
			//The last line of SMTP replies have a space after the status number
			// They do NOT have an EOF so while(!feof($socket)) will hang!
			if (!preg_match('/^\d+\ /', $tmp)) $ret .= $tmp;
			else
			{
				$ret .= $tmp;
				break;
			}
		}
		
		$this->responseCode = $this->getResponseCode($ret);
		$this->lastResponse = $ret;
		
		$this->runPluginMethods('onResponse');
		
		return $this->lastResponse;
	}

	private function getResponseCode($string)
	{
		if (preg_match('/^\d+/', $string, $matches))
		{
			return (int) $matches[0];
		}
		else return 0;
	}

	private function getCommandKeyword($comm)
	{
		if (preg_match('/^\s*([\S]+)/', $comm, $matches))
		{
			if (!empty($matches[1])) return strtolower($matches[1]);
			else return '';
		}
		else return '';
	}

	//Issue a command the the connection
	public function command($comm)
	{
		$this->currentCommand = $comm;
		
		$this->runPluginMethods('onBeforeCommand');
		
		if (!$this->connection->writeHook || $this->failed) return false;

		$command_keyword = $this->getCommandKeyword($this->currentCommand);
		
		//SMTP commands must end with CRLF
		if (substr($this->currentCommand, -2) != "\r\n") $this->currentCommand .= "\r\n";
		
		if (@fwrite($this->connection->writeHook, $this->currentCommand))
		{
			$this->logTransaction($this->currentCommand);
			if (array_key_exists($command_keyword, $this->expectedCodes))
			{
				if ($this->expectedCodes[$command_keyword] != $this->responseCode)
				{
					$this->failed = true;
					$this->logError($this->lastResponse, $this->responseCode);
					return false;
				}
			}
			$this->runPluginMethods('onCommand');
			return $this->lastResponse;
		}
		else return false;
	}

	//Add a part to a multipart message
	public function addPart($string, $type='text/plain', $encoding='7bit')
	{
		$ret = "Content-Type: $type\r\n".
				"Content-Transfer-Encoding: $encoding\r\n\r\n".
				$this->encode($string, $encoding);
		$this->parts[] = $ret;
	}

	//Attachments are added as base64 encoded data
	public function addAttachment($data, $filename, $type)
	{
		$ret = "Content-Type: $type; ".
				"name=\"$filename\";\r\n".
				"Content-Transfer-Encoding: base64\r\n".
				"Content-Disposition: attachment;\r\n\r\n".
				chunk_split($this->encode($data, 'base64'));
		$this->attachments[] = $ret;
	}

	public function close()
	{
		if ($this->connection->writeHook)
		{
			$this->command("quit\r\n");
			$this->connection->stop();
		}
		$this->runPluginMethods('onClose');
	}

	public function hasFailed()
	{
		return $this->failed;
	}

	public function fail()
	{
		$this->runPluginMethods('onFail');
		$this->failed = true;
	}

	private function encode($string, $type)
	{
		$type = strtolower($type);
		
		switch ($type)
		{
			case 'base64':
			$string = base64_encode($string);
			break;
			//
			case 'quoted-printable':
			$string = $this->quotedPrintableEncode($string);
			//
			case '7bit':
			default:
			break;
		}
		
		return $string;
	}

	//From php.net by user bendi at interia dot pl
	private function quotedPrintableEncode($string)
	{
		$string = preg_replace('/[^\x21-\x3C\x3E-\x7E\x09\x20]/e', 'sprintf( "=%02x", ord ( "$0" ) ) ;', $string);
		preg_match_all('/.{1,73}([^=]{0,3})?/', $string, $matches);
		return implode("=\r\n", $matches[0]);
	}

	//Because SMTP data transmissions end with CRLF <dot> CRLF
	// we need to remove any of these from the string.
	private function escapeDot($string)
	{
		$stack = array();
		$lines = explode("\r\n", $string);
		foreach ($lines as $l)
		{
			if ($l == '.') $l = ' .'; //Quite simple... just add some whitespace padding!
			$stack[] = $l;
		}
		return implode("\r\n", $stack);
	}

	private function makeRecipientHeaders($address)
	{
		return "To: $address\r\n";
	}

	//Builds the list of commands to send the email
	private function buildMail($to, $from, $subject, $body, $type='text/plain', $encoding='7bit', $return_data_part=true)
	{
		$date = date('r'); //RFC 2822 date

		if ($return_data_part)
		{
			if ($body) //Ignore multipart messaging
			{
				$data = "From: $from\r\n".
					"Reply-To: $from\r\n".
					"Subject: $subject\r\n".
					"Date: $date\r\n".
					"{$this->headers}".
					"Content-Type: $type\r\n".
					"Content-Transfer-Encoding: $encoding\r\n\r\n".
					$this->escapeDot($this->encode($body, $encoding));
			}
			else //Build a multipart message
			{
				$boundary = $this->getMimeBoundary(); //Overall MIME boundary
				
				$message_body = implode("\r\n\r\n--$boundary\r\n", $this->parts);
	
				if (!empty($this->attachments)) //Make a sub-message that contains attachment data
				{
					$attachment_boundary = $this->getMimeBoundary(implode('', $this->attachments));
					
					$attachments = implode("\r\n\r\n--$attachment_boundary\r\n", $this->attachments);
					
					$attachments = "\r\n\r\n--$boundary\r\n".
					"Content-Type: multipart/alternative;\r\n".
					"	boundary=\"$attachment_boundary\"\r\n".
					"Content-Transfer-Encoding: 7bit\r\n\r\n".
					"\r\n\r\n--$attachment_boundary\r\n".
					$attachments.
					"\r\n--$attachment_boundary--\r\n";
				}
				
				$data = "From: $from\r\n".
					"Reply-To: $from\r\n".
					"Subject: $subject\r\n".
					"Date: $date\r\n".
					"{$this->headers}".
					"MIME-Version: 1.0\r\n".
					"Content-Type: multipart/mixed;\r\n".
					"	boundary=\"{$boundary}\"\r\n".
					"Content-Transfer-Encoding: 7bit\r\n\r\n";
	
				$data .= $this->mimeWarning;
				
				if (isset($attachments)) $message_body .= $attachments;
				
				$data .= "\r\n\r\n--$boundary\r\n".
					"$message_body\r\n".
					"\r\n--$boundary--";
			}
	
			$data = $this->escapeDot($data);
	
			$data .= "\r\n.\r\n";
		}
		
		$ret = array(
			//Can be spoofed but that's not my problem
			"mail from: $from\r\n",
			//Recipient
			"rcpt to: $to\r\n",
			//Inform that we're about the give our message
			"data\r\n"
		);
		
		if ($return_data_part) $ret[] = $data;

		return $ret;
	}
}

?>