<?php
//
// RFC822 Email validation Utility
//

//
// +----------------------------------------------------------------------+
// | Author:  Richard Heyes <richard.heyes@heyes-computing.net>           |
// | Version: 1.00                                                        |
// | Updated: 19 November 20000                                           |
// +----------------------------------------------------------------------+

class Mail_Rfc822{

	var $address;
	var $addresses;
	var $default_host;
	var $ext_format;
	var $structure;
	var $error;
	var $index;

	/***************************************
    ** Constructor
    ***************************************/
	function Mail_Rfc822($address = NULL, $default_host = NULL, $extended_format = TRUE){

		$this->address      = $address;
		$this->default_host = $default_host;
		$this->ext_format   = TRUE;
		$this->structure    = array();
		$this->addresses    = array();
		$this->error		= NULL;
		$this->index		= NULL;
	}

	/***************************************
    ** Method that can be called statically.
	** Will create object, run the thing,
	** and then unset the object.
    ***************************************/
	function statParseAddressList($address, $default_host = 'localhost', $extended_format = FALSE){

		if(function_exists('imap_rfc822_parse_adrlist') AND $extended_format === FALSE)
			return imap_rfc822_parse_adrlist($address, $default_host);

		$obj    = new Mail_Rfc822($address, $default_host, TRUE);
		$return = $obj->parseAddressList();

		unset($obj);
		return $return;		
	}
	
	/***************************************
	** Starts the whole process.
	***************************************/
	function parseAddressList($address = NULL, $default_host = NULL){

		if(isset($address))      $this->address      = $address;
		if(isset($default_host)) $this->default_host = $default_host;


		if($this->split_addresses($this->address) === FALSE OR isset($this->error))
			return (array)$this->error;

		for($i=0; $i<count($this->addresses); $i++){
			if(($return = $this->validate_address($this->addresses[$i])) === FALSE OR isset($this->error))
				return (array)$this->error;

			if($this->ext_format === FALSE)
				$this->structure = array_merge($this->structure, $return);
			else
				$this->structure[] = $return;
		}

		$return = $this->structure;
			
		// Now clean up.
			
		$this->address      = $address;
		$this->default_host = $default_host;
		$this->structure    = array();
		$this->addresses    = array();
		$this->error		= NULL;
		$this->index		= NULL;
			
		return $return;
	}

	/***************************************
	** Splits an address into seperate addresses.
	***************************************/
	function split_addresses($address = ''){

		if($this->is_group($address) == TRUE AND !isset($this->error)){

			$split_char = ';';
			$is_group   = TRUE;

		}elseif(!isset($this->error)){

			$split_char = ',';
			$is_group   = FALSE;

		}elseif(isset($this->error))

			return FALSE;



		// Starts with a group, so split on semi-colon:
			
		$parts  = explode($split_char, $address);
		$string = $this->split_check($parts, $split_char);


		// If a group...
		
		if($is_group === TRUE){
		
			// If $string does not contain a colon outside of
			// brackets/quotes etc then something's fubar.
			
			// First check there's a colon at all:
			
			if(strpos($string, ':') === FALSE){
				$this->error = 'Invalid address: '.$string;
				return FALSE;
			}
			
			// Now check it's outside of brackets/quotes:
			
			if($this->split_check(explode(':', $string), ':') == FALSE)
				return FALSE;
		}

		// $string now contains the first full address/group.
		// Add to the addresses array.

		$this->addresses[] = array(
									'address' => trim($string),
									'group'   => $is_group
								  );

		// Remove the now stored address from the initial line, the +1
		// is to account for the explode character.
			
		$address = trim(substr($address, strlen($string)+1 ));

		// If the next char is a comma and this was a group,
		// then there are more addresses, otherwise, if there are
		// any more chars, then there is another address.
			
		if($is_group === TRUE AND substr($address,0,1) == ','){
			$address = trim(substr($address,1));
			$this->split_addresses($address);
			return TRUE;

		}elseif(strlen($address) > 0){
			$this->split_addresses($address);
			return TRUE;
		
		}else{
			return TRUE;
		}

		// If you've got here then something's barfed.

		return FALSE;
	}

	/***************************************
	** Checks for a group at the start of
	** the string.
	***************************************/
	function is_group($address){

		// First comma not in quotes, angles or escaped:
		
		$parts  = explode(',', $address);
		$string = $this->split_check($parts, ',');
					
		/***************************************
		** Now we have the first address, we can
		** reliably check for a group by searching
		** for a colon that's not escaped or in
		** quotes or angle brackets.
		***************************************/
		
		if(count($parts = explode(':', $string)) > 1){
		
			$string2 = $this->split_check($parts, ':');
			if($string2 === $string)
				return FALSE;
						
			return TRUE;

		}else{
		
			return FALSE;
		}
	}

	/***************************************
	** A common function that will check
	** an exploded string.
	** Used by: split_addresses()
	**  is_group()
	** $parts = The exploded string
	** $char  = The char that was exploded on.
	***************************************/
	function split_check($parts, $char){

		$string = $parts[0];

		for($i=0; $i<count($parts); $i++){

			if($this->has_unclosed_quotes($string) == TRUE OR $this->has_unclosed_brackets($string, '<>') == TRUE OR $this->has_unclosed_brackets($string, '[]') == TRUE OR substr($string, -1) == '\\'){

				if(isset($parts[$i+1])){
					$string = $string.$char.$parts[$i+1];
				}else{
					$this->error = 'Invalid address spec. Unclosed bracket or quotes';
					return FALSE;
				}

			}else{
				$this->index = $i;
				break;
			}
		}

		return $string;
	}

	/***************************************
	** Checks if a string has an unclosed
	** quotes or not.
	***************************************/
	function has_unclosed_quotes($string){
	
		$string     = explode('"', $string);
		$string_cnt = count($string);
		
		for($i=0; $i<(count($string)-1); $i++)
			if(substr($string[$i], -1) == '\\')
				$string_cnt--;

		if($string_cnt % 2 === 0)
			return TRUE;
		else
			return FALSE;
	}

	/***************************************
	** Checks if a string has an unclosed
	** brackets or not.
	** IMPORTANT: This function handles
	** both angle brackets and square brackets;
	***************************************/
	function has_unclosed_brackets($string, $chars){
	
		$num_angle_start = substr_count($string, $chars[0]);
		$num_angle_end   = substr_count($string, $chars[1]);

		$num_angle_start = $this->has_unclosed_brackets_sub($string, $num_angle_start, $chars[0]);
		$num_angle_end   = $this->has_unclosed_brackets_sub($string, $num_angle_end,   $chars[1]);

		if($num_angle_start < $num_angle_end){

			$this->error = 'Invalid address spec. Unmatched quote or bracket ('.$chars.')';
			return FALSE;

		}elseif($num_angle_start > $num_angle_end)
			return TRUE;

		else return FALSE;
	}

	/***************************************
	** Sub function that is used only be
	** has_unclosed_angles.
	***************************************/
	function has_unclosed_brackets_sub($string, $num, $char){
	
		$parts = explode($char, $string);
		for($i=0; $i<count($parts); $i++){
	
			if(substr($parts[$i],-1) == '\\' OR $this->has_unclosed_quotes($parts[$i])) $num--;
	
			if(isset($parts[$i+1])) $parts[$i+1] = $parts[$i].$char.$parts[$i+1];
	
		}
	
		return $num;
	}


	/***************************************
	** Function to begin checking the address.
	***************************************/
	function validate_address($address){
	
		$is_group = FALSE;
	
		if($address['group'] == TRUE){

			$is_group = TRUE;

			// Get the group part of the name
			$parts     = explode(':', $address['address']);
			$groupname = $this->split_check($parts, ':');

			$structure = array();
			
			// And validate the group part of the name.
			if(!$this->validate_phrase($groupname)){
		
				$this->error = 'Group name did not validate.';
				return FALSE;

			}else{

				// Do differing things based upon format type.
							
				if($this->ext_format === FALSE){

					$temp_obj          = new stdClass;
					$temp_obj->mailbox = $groupname;
					 
					$structure[] = $temp_obj;
					unset($temp_obj);

				}else{
				
					$structure = new stdClass;
					$structure->groupname = $groupname;
				}
			}

			$address['address'] = ltrim( substr($address['address'], strlen($groupname.':')) );
		}
	
		// If a group then split on comma and bung in an array.
		// Otherwise, Just bung the whole address in an array.
		if($is_group == TRUE){
			while(trim(strlen($address['address'])) > 0){

				$parts       = explode(',', $address['address']);
				$addresses[] = $this->split_check($parts, ',');

				$address['address'] = substr($address['address'], strlen(end($addresses).','));
			}

		}else{
			$addresses[] = $address['address'];
		}
	
		// Check that $addresses is set, if address like this:
		// Groupname:;
		// Then errors were appearing.
		if(!isset($addresses)){
			$this->error[] = 'Empty group.';
			return FALSE;
		}
				
		// Trim those bad boys...
		for($i=0; $i<count($addresses); $i++)
			$addresses[$i] = trim($addresses[$i]);
	
		// Validate the mailbox.
		// This could be one of: name <geezer@domain.com>
		//       				 geezer@domain.com
		//       				 geezer
		//
		// Other combinations/variations are also possible

		for($i=0; $i<count($addresses); $i++)
			$addresses[$i] = $this->validate_mailbox($addresses[$i]);

		// NORMAL format
		if($this->ext_format === FALSE){
		
			if($is_group === TRUE)
				$structure = array_merge($structure, $addresses, array(new stdClass));
			else
				$structure = $addresses;
				
		// EXTENDED Format
		}else{
			if($is_group === TRUE)
				$structure->addresses = $addresses;
			else
				$structure = $addresses;
		}
		
		// Finished.
		return $structure;
	}

	/***************************************
	** Function to validate a phrase.
	***************************************/
	function validate_phrase($phrase){

		// Splits on one or more Tab or space.
		$parts = preg_split('/[ \\x09]+/', $phrase, 0, PREG_SPLIT_NO_EMPTY);

		while(count($parts) > 0){
			$phrase_parts[] = $this->split_check($parts, ' ');
			for($i=0; $i < $this->index + 1; $i++) array_shift($parts);
		}

		for($i=0; $i<count($phrase_parts); $i++){

			// If quoted string:
			if(substr($phrase_parts[$i],0,1) == '"'){
				if(!$this->validate_quoted_string($phrase_parts[$i])) return FALSE;
				continue;
			}

			// Otherwise its an atom:
			if(!$this->validate_atom($phrase_parts[$i])) return FALSE;
		}

		return TRUE;
	}

	/***************************************
	** Function to validate an atom which
	** from rfc822 is:
	** atom =  1*<any CHAR except specials, SPACE and CTLs>
	***************************************/
	function validate_atom($atom){

		// Check for any char from ASCII 0 - ASCII 127
		if(!preg_match('/^[\\x00-\\x7E]+$/i', $atom, $matches))
			return FALSE;

		// Check for specials:
		if(preg_match('/[][()<>@,;\\:". ]/', $atom))
			return FALSE;

		// Check for control characters (ASCII 0-31):
		if(preg_match('/[\\x00-\\x1F]+/', $atom))
			return FALSE;

		return TRUE;

	}

	/***************************************
	** Function to validate quoted string,
	** which is:
	** quoted-string = <"> *(qtext/quoted-pair) <">
	***************************************/
	function validate_quoted_string($qstring){

		// Leading and trailing "
		$qstring = substr($qstring, 1, -1);

		// Perform check.
		if(preg_match('/(.)[\x0D\\\\"]/', $qstring, $matches) AND $matches[1] != '\\') return FALSE;
		return TRUE;

	}

	/***************************************
	** Function to validate a mailbox,
	** which is:
	** mailbox =  addr-spec ; simple address
	** /  phrase route-addr ; name and route-addr
	***************************************/
	function validate_mailbox($mailbox){

		// A couple of defaults.
		$phrase = '';
			
		// Check for name + route-addr
		if(substr($mailbox,-1) == '>' AND substr($mailbox,0,1) != '<'){
			$parts  = explode('<', $mailbox);
			$name   = $this->split_check($parts, '<');

			$phrase     = trim($name);
			$route_addr = trim(substr($mailbox, strlen($name.'<'), -1));

			if($this->validate_phrase($phrase) === FALSE OR ($route_addr = $this->validate_route_addr($route_addr)) === FALSE)
				return FALSE;


		// Only got addr-spec
		}else{
			// First snip angle brackets if present.
			if(substr($mailbox,0,1) == '<' AND substr($mailbox,-1) == '>')
				$addr_spec = substr($mailbox,1,-1);
			else
				$addr_spec = $mailbox;
					
			if(($addr_spec = $this->validate_addr_spec($addr_spec)) === FALSE)
				return FALSE;
		}

			
		// Construct the object that will be returned.
		$mbox = new stdClass();
		$phrase !== '' ? $mbox->personal = $phrase : '';

		if(isset($route_addr)){
		
			$mbox->mailbox = $route_addr['local_part'];
			$mbox->host    = $route_addr['domain'];
			
			$route_addr['adl'] !== '' ? $mbox->adl = $route_addr['adl'] : '';
			
		}else{
		
			$mbox->mailbox = $addr_spec['local_part'];
			$mbox->host    = $addr_spec['domain'];
			
		}
		
		return $mbox;
	}

	/***************************************
	** This function validates a route-addr
	** which is:
	** route-addr  =  "<" [route] addr-spec ">"
	**
	** Angles have already been removed at the
	** point of getting to this function.
	***************************************/
	function validate_route_addr($route_addr){

		// Check for colon.
		if(strpos($route_addr, ':') !== FALSE){
			$parts = explode(':', $route_addr);
			$route = $this->split_check($parts, ':');

		}else{
			$route = $route_addr;
		}

		// If $route is same as $route_addr then the colon
		// was in quotes or brackets or, of course, non existent.
		if($route === $route_addr){
			unset($route);
			$addr_spec = $route_addr;
			if(($addr_spec = $this->validate_addr_spec($addr_spec)) === FALSE)
				return FALSE;

		}else{
			// Validate route part.
			if(($route = $this->validate_route($route)) === FALSE)
				return FALSE;

			$addr_spec = substr($route_addr, strlen($route.':'));

			// Validate addr-spec part.
			if(($addr_spec = $this->validate_addr_spec($addr_spec)) === FALSE) return FALSE;
		}

		if(isset($route))
			$return['adl'] = $route; else $return['adl'] = '';

			$return = array_merge($return, $addr_spec);
			return $return;
	}

	/***************************************
	** Function to validate a route, which is:
	** route = 1#("@" domain) ":"
	***************************************/
	function validate_route($route){

		// Split on comma.
		$domains = explode(',', trim($route));

		for($i=0; $i<count($domains); $i++){
			$domains[$i] = str_replace('@', '', trim($domains[$i]));
			if($this->validate_domain($domains[$i]) === FALSE) return FALSE;
		}

		return $route;
	}
		
	/***************************************
	** Function to validate a domain, though
	** this is not quite what you expect of
	** a strict internet domain.
	**
	** domain = sub-domain *("." sub-domain)
	***************************************/
	function validate_domain($domain){

		// Note the different use of $subdomains and $sub_domains						
		$subdomains = explode('.', $domain);

		while(count($subdomains) > 0){
			$sub_domains[] = $this->split_check($subdomains, '.');
			for($i=0; $i < $this->index + 1; $i++) array_shift($subdomains);
		}
			
		for($i=0; $i<count($sub_domains); $i++)
		if($this->validate_subdomain(trim($sub_domains[$i])) === FALSE) return FALSE;
				
		// Managed to get here, so return input.
		return $domain;
	}
		
	/***************************************
	** Function to validate a subdomain.
	**
	** subdomain = domain-ref / domain-literal
	***************************************/
	function validate_subdomain($subdomain){

		if(preg_match('|^\[(.*)]$|', $subdomain, $arr)){
			if($this->validate_dliteral($arr[1]) === FALSE) return FALSE;
		}else{
			if($this->validate_atom($subdomain) === FALSE) return FALSE;
		}
			
		// Got here, so return successful.
		return TRUE;
	}
		
	/***************************************
	** Function to validate a domain literal
	** 
	** domain-literal =  "[" *(dtext / quoted-pair) "]"
	***************************************/
	function validate_dliteral($dliteral){
		
		if(preg_match('/(.)[][\x0D\\\\]/', $dliteral, $matches) AND $matches[1] != '\\')
			return FALSE; 
		
		return TRUE;
	}
		
	/***************************************
	** Function to validate an addr-spec.
	**
	** addr-spec = local-part "@" domain
	***************************************/
	function validate_addr_spec($addr_spec){

		$addr_spec = trim($addr_spec);

		// Split on @ sign if there is one.
		if(strpos($addr_spec, '@') !== FALSE){

			$parts      = explode('@', $addr_spec);
			$local_part = $this->split_check($parts, '@');							
			$domain     = substr($addr_spec, strlen($local_part.'@'));			

		// No @ sign so assume the default domain.
		}else{
			$local_part = $addr_spec;
			$domain     = $this->default_host;
		}

		if(($local_part = $this->validate_local_part($local_part)) === FALSE) return FALSE;
		if(($domain     = $this->validate_domain($domain)) === FALSE) return FALSE;

		// Got here so return successful.
		return array('local_part' => $local_part, 'domain' => $domain);
	}

	/***************************************
	** Function to validate the local part
	** of an address.
	**
	** local-part = word *("." word)
	***************************************/
	function validate_local_part($local_part){

		$parts = explode('.', $local_part);

		// Split the local_part into words.
		while(count($parts) > 0){
			$words[] = $this->split_check($parts, '.');
			for($i=0; $i < $this->index + 1; $i++)
				array_shift($parts);
		}

		// Validate each word.
		for($i=0; $i<count($words); $i++)
			if($this->validate_phrase(trim($words[$i])) === FALSE) return FALSE;

		// Managed to get here, so return the input.
		return $local_part;
	}
}
?>