<?php namespace ProcessWire;

/**
 * ProcessWire WireMail
 * 
 * ProcessWire 3.x, Copyright 2019 by Ryan Cramer
 * https://processwire.com
 * 
 * #pw-summary A module type that handles sending of email in ProcessWire
 * #pw-var $m
 * #pw-body = 
 *
 * Below are 3 different ways you can get a new instance of WireMail. 
 * When possible we recommend using option A or B below.  
 * ~~~~~
 * $m = $mail->new(); // option A
 * $m = wireMail(); // option B
 * $m = new WireMail(); // option C
 * ~~~~~
 * Once you have an instance of WireMail (`$m`), you can use it to send email like in these examples below. 
 * ~~~~~
 * // chained (fluent) method call usage
 * $m->to('user@domain.com')
 *   ->from('you@company.com')
 *   ->subject('Message Subject')
 *   ->body('Message Body')
 *   ->send();
 *
 * // separate method call usage
 * $m->to('user@domain.com'); // specify CSV string or array for multiple addresses
 * $m->from('you@company.com'); 
 * $m->subject('Message Subject'); 
 * $m->body('Message Body'); 
 * $m->send();
 *
 * // optionally specify “from” or “to” names as 2nd argument 
 * $m->to('user@domain.com', 'John Smith');
 * $m->from('you@company.com', 'Mary Jane'); 
 *
 * // other methods or properties you might set (or get)
 * $m->bodyHTML('<html><body><h1>Message Body</h1></body></html>'); 
 * $m->attachment('/path/to/file.ext'); 
 * $m->fromName('Mary Jane');
 * $m->toName('John Smith');
 * $m->header('X-Mailer', 'ProcessWire'); 
 * $m->param('-f you@company.com'); // PHP mail() param (envelope from example)
 *
 * // note that the send() function always returns the quantity of messages sent
 * $numSent = $m->send();
 * ~~~~~
 * #pw-body
 * 
 * @method int send() Send email. 
 * @method string htmlToText($html) Convert HTML email body to TEXT email body. 
 * @method string sanitizeHeaderName($name) #pw-internal
 * @method string sanitizeHeaderValue($value) #pw-internal
 * 
 * @property array $to To email address.
 * @property array $toName Optional person’s name to accompany “to” email address
 * @property string $from From email address. 
 * @property string $fromName Optional person’s name to accompany “from” email address. 
 * @property string $replyTo Reply-to email address (where supported). #pw-advanced
 * @property string $replyToName Optional person’s name to accompany “reply-to” email address. #pw-advanced
 * @property string $subject Subject line of email.
 * @property string $body Plain text body of email.
 * @property string $bodyHTML HTML body of email. 
 * @property array $header Associative array of additional headers.
 * @property array $headers Alias of $header
 * @property array $param Associative array of aditional params (likely not applicable to most WireMail modules). 
 * @property array $attachments Array of file attachments (if populated and where supported) #pw-advanced
 * @property string $newline Newline character, populated only if different from CRLF. #pw-advanced
 * 
 *
 */

class WireMail extends WireData implements WireMailInterface {

	/**
	 * Mail properties
	 *
	 */
	protected $mail = array(
		'to' => array(), // to addresses - associative: both key and value are email (to prevent dups)
		'toName' => array(), // to names - associative: indexed by 'to' email address, may be blank/null for any email 
		'from' => '', 
		'fromName' => '', 
		'replyTo' => '',
		'replyToName' => '', 
		'subject' => '', 
		'body' => '',
		'bodyHTML' => '',
		'header' => array(),
		'param' => array(), 
		'attachments' => array(), 
		);

	/**
	 * Construct
	 * 
	 */
	public function __construct() {
		$this->mail['header']['X-Mailer'] = "ProcessWire/" . $this->className();
		parent::__construct();
	}

	/**
	 * Get property
	 * 
	 * @param string $key
	 * @return mixed|null
	 * 
	 */
	public function get($key) {
		if($key === 'headers') $key = 'header';
		if(array_key_exists($key, $this->mail)) return $this->mail[$key]; 
		return parent::get($key);
	}

	/**
	 * Set property
	 * 
	 * @param string $key
	 * @param mixed $value
	 *
	 * @return $this|WireData
	 * 
	 */
	public function set($key, $value) {
		if($key === 'headers' || $key === 'header') {
			if(is_array($value)) $this->headers($value); 
		} else if(array_key_exists($key, $this->mail)) {
			$this->$key($value); // function call
		} else {
			parent::set($key, $value);
		}
		return $this;
	}
	
	public function __get($key) { return $this->get($key); }
	public function __set($key, $value) { return $this->set($key, $value); }

	/**
	 * Sanitize an email address or throw WireException if invalid or in blacklist
	 * 
	 * @param string $email
	 * @return string
	 * @throws WireException
	 * 
	 */
	protected function sanitizeEmail($email) {
		if(!strlen($email)) return '';
		$email = strtolower(trim($email)); 
		if(strpos($email, ':') && preg_match('/^(.+):\d+$/', $email, $matches)) {
			// sending email in particular might sometimes be auto-generated from hostname
			// so remove trailing port, i.e. ':8888', if present since it will not validate
			$email = $matches[1]; 
		}
		$clean = $this->wire('sanitizer')->email($email); 
		if($email !== $clean) {
			throw new WireException("Invalid email address: " . $this->wire('sanitizer')->entities($email));
		}
		/** @var WireMailTools $mail */
		$mail = $this->wire('mail');
		if($mail && $mail->isBlacklistEmail($email)) {
			throw new WireException("Email address not allowed: " . $this->wire('sanitizer')->entities($email));
		}
		return $clean;
	}

	/**
	 * Sanitize and normalize a header name
	 *
	 * @param string $name
	 * @return string
	 * @since 3.0.132
	 *
	 */
	protected function ___sanitizeHeaderName($name) {
		/** @var Sanitizer $sanitizer */
		$sanitizer = $this->wire('sanitizer');
		$name = $sanitizer->emailHeader($name, true);
		// ensure consistent capitalization for header names
		$name = ucwords(str_replace('-', ' ', $name));
		$name = str_replace(' ', '-', $name);
		return $name;
	}

	/**
	 * Sanitize an email header header value
	 *
	 * @param string $value
	 * @return string
	 * @since 3.0.132
	 *
	 */
	protected function ___sanitizeHeaderValue($value) {
		return $this->wire('sanitizer')->emailHeader($value); 
	}

	/**
	 * Alias of sanitizeHeaderValue() method for backwards compatibility
	 * 
	 * #pw-internal
	 *
	 * @param string $header
	 * @return string
	 *
	 */
	protected function sanitizeHeader($header) {
		return $this->sanitizeHeaderValue($header);
	}

	/**
	 * Given an email string like "User <user@example.com>" extract and return email and username separately
	 *
	 * @param string $email
	 * @return array() Index 0 contains email, index 1 contains username or blank if not set
	 *
	 */
	protected function extractEmailAndName($email) {
		$name = '';
		if(strpos($email, '<') !== false && strpos($email, '>') !== false) {
			// email has separate from name and email
			if(preg_match('/^(.*?)<([^>]+)>.*$/', $email, $matches)) {
				$name = $this->sanitizeHeaderValue($matches[1]);
				$email = $matches[2]; 
			}
		}
		$email = $this->sanitizeEmail($email); 
		return array($email, $name); 
	}

	/**
	 * Given an email and name, bundle it to an RFC 2822 string
	 *
	 * If name is blank, then just the email will be returned
	 *
	 * @param string $email
	 * @param string $name
	 * @return string
	 *
	 */
	protected function bundleEmailAndName($email, $name) {
		$email = $this->sanitizeEmail($email); 
		if(!strlen($name)) return $email;
		$name = $this->sanitizeHeaderValue($name); 
		$delim = '';
		if(strpos($name, ',') !== false) {
			// name contains a comma, so quote the value
			$name = str_replace('"', '', $name); // remove existing quotes
			$delim = '"';  // add quotes
		}
		// Encode the name part as quoted printable according to rfc2047
		return $delim . $this->quotedPrintableString($name) . $delim . " <$email>";
	}

	/**
	 * Set the email to address
	 *
	 * Each added email addresses appends to any addresses already supplied, unless
	 * you specify NULL as the email address, in which case it clears them all.
	 *
	 * @param string|array|null $email Specify any ONE of the following: 
	 * - Single email address or "User Name <user@example.com>" string.
	 * - CSV string of #1. 
	 * - Non-associative array of #1. 
	 * - Associative array of (email => name)
	 * - NULL (default value, to clear out any previously set values)
	 * @param string $name Optionally provide a TO name, applicable
	 *	only when specifying #1 (single email) for the first argument. 
	 * @return $this 
	 * @throws WireException if any provided emails were invalid or in blacklist
	 *
	 */
	public function to($email = null, $name = null) {

		if(is_null($email)) { 
			// clear existing values
			$this->mail['to'] = array(); 
			$this->mail['toName'] = array();
			return $this; 
		}

		$emails = is_array($email) ? $email : explode(',', $email); 

		foreach($emails as $key => $value) {

			$toName = '';
			if(is_string($key)) {
				// associative array
				// email provided as $key, and $toName as value 
				$toEmail = $key; 
				$toName = $value; 

			} else if(strpos($value, '<') !== false && strpos($value, '>') !== false) {
				// toName supplied as: "User Name <user@example.com"
				list($toEmail, $toName) = $this->extractEmailAndName($value); 

			} else {
				// just an email address, possibly with name as a function arg
				$toEmail = $value; 
			}

			if(empty($toName)) $toName = $name; // use function arg if not overwritten
			$toEmail = $this->sanitizeEmail($toEmail); 
			if(strlen($toEmail)) {
				$this->mail['to'][$toEmail] = $toEmail;
				$this->mail['toName'][$toEmail] = $this->sanitizeHeaderValue($toName);
			}
		}

		return $this; 
	}

	/**
	 * Set the 'to' name
	 *
	 * It is preferable to do this with the to() method, but this is provided to ensure that 
	 * all properties can be set with direct access, i.e. $mailer->toName = 'User Name';
	 *
 	 * This sets the 'to name' for whatever the last added 'to' email address was.
	 *
	 * @param string $name The 'to' name
	 * @return $this 
	 * @throws WireException if you attempt to set a toName before a to email. 
	 *
	 */
	public function toName($name) {
		$emails = $this->mail['to']; 
		if(!count($emails)) throw new WireException("Please set a 'to' address before setting a name."); 
		$email = end($emails); 
		$this->mail['toName'][$email] = $this->sanitizeHeaderValue($name); 
		return $this;
	}

	/**
	 * Set the email 'from' address and optionally name
	 *
	 * @param string $email Must be a single email address or "User Name <user@example.com>" string.
	 * @param string|null An optional FROM name (same as setting/calling fromName)
	 * @return $this 
	 * @throws WireException if provided email was invalid or in blacklist
	 *
	 */
	public function from($email, $name = null) {
		if(is_null($name)) {
			list($email, $name) = $this->extractEmailAndName($email);
		} else {
			$email = $this->sanitizeEmail($email);
		}
		if($name) $this->fromName($name); 
		$this->mail['from'] = $email;
		return $this; 
	}

	/**
	 * Set the 'from' name
	 *
	 * It is preferable to do this with the from() method, but this is provided to ensure that 
	 * all properties can be set with direct access, i.e. $mailer->fromName = 'User Name';
	 *
	 * @param string $name The 'from' name
	 * @return $this 
	 *
	 */
	public function fromName($name) {
		$this->mail['fromName'] = $this->sanitizeHeaderValue($name); 
		return $this; 
	}

	/**
	 * Set the 'reply-to' email address and optionally name (where supported)
	 *
	 * @param string $email Must be a single email address or "User Name <user@example.com>" string.
	 * @param string|null An optional Reply-To name (same as setting/calling replyToName method)
	 * @return $this
	 * @throws WireException if provided email was invalid or in blacklist
	 *
	 */
	public function replyTo($email, $name = null) {
		if(is_null($name)) {
			list($email, $name) = $this->extractEmailAndName($email);
		} else {
			$email = $this->sanitizeEmail($email);
		}
		if($name) $this->mail['replyToName'] = $this->sanitizeHeaderValue($name); 
		$this->mail['replyTo'] = $email;
		if(empty($name) && !empty($this->mail['replyToName'])) $name = $this->mail['replyToName']; 
		if(strlen($name)) $email = $this->bundleEmailAndName($email, $name); 
		$this->header('Reply-To', $email); 
		return $this; 
	}

	/**
	 * Set the 'reply-to' name (where supported)
	 * 
	 * @param string $name
	 * @return $this
	 * 
	 */
	public function replyToName($name) {
		if(strlen($this->mail['replyTo'])) return $this->replyTo($this->mail['replyTo'], $name); 
		$this->mail['replyToName'] = $this->sanitizeHeaderValue($name);
		return $this; 
	}

	/**
	 * Set the email subject
	 *
	 * @param string $subject Email subject text
	 * @return $this 
	 *
	 */
	public function subject($subject) {
		$this->mail['subject'] = $this->sanitizeHeaderValue($subject); 	
		return $this; 
	}

	/**
	 * Set the email message body (text only)
	 * 
	 * ~~~~~
	 * $m = wireMail();
	 * $m->body('Hello world');
	 * ~~~~~
	 *
	 * @param string $body Email body in text only
	 * @return $this 
	 *
	 */
	public function body($body) {
		$this->mail['body'] = $body; 
		return $this; 
	}

	/**
	 * Set the email message body (HTML only)
	 * 
	 * This should be the text from an entire HTML document, not just an element.
	 * 
	 * ~~~~~
	 * $m = wireMail();
	 * $m->bodyHTML('<html><body><h1>Hello world</h1></body></html>');
	 * ~~~~~
	 *
	 * @param string $body Email body in HTML
	 * @return $this 
	 *
	 */
	public function bodyHTML($body) {
		$this->mail['bodyHTML'] = $body; 
		return $this; 
	}

	/**
	 * Set any email header
	 *
	 * - Multiple calls will append existing headers. 
	 * - To remove an existing header, specify NULL as the value. 
	 * 
	 * #pw-advanced
	 *
	 * @param string|array $key Header name
	 * @param string $value Header value or specify null to unset
	 * @return $this 
	 *
	 */
	public function header($key, $value) {
		if(is_null($value)) {
			if(is_array($key)) {
				$this->headers($key);
			} else {
				$key = $this->sanitizeHeaderName($key);
				unset($this->mail['header'][$key]);
			}
		} else {
			$key = $this->sanitizeHeaderName($key);
			$value = $this->sanitizeHeaderValue($value); 
			if(strlen($key)) $this->mail['header'][$key] = $value; 
		}
		return $this; 
	}

	/**
	 * Set multiple email headers using associative array
	 * 
	 * @param array $headers
	 * @return $this
	 * 
	 */
	public function headers(array $headers) {
		foreach($headers as $key => $value) {
			$this->header($key, $value); 
		}
		return $this;
	}

	/**
	 * Set any email param 
	 *
	 * See `$additional_parameters` at <http://www.php.net/manual/en/function.mail.php>
	 * 
	 * - Multiple calls will append existing params. 
	 * - To remove an existing param, specify NULL as the value. 
	 * 
	 * This function may only be applicable if you don't have other WireMail modules
	 * installed as email params are only used by PHP's `mail()` function. 
	 * 
	 * #pw-advanced
	 *
	 * @param string $value
	 * @return $this 
	 *
	 */
	public function param($value) {
		if(is_null($value)) {
			$this->mail['param'] = array();
		} else { 
			$this->mail['param'][] = $value; 
		}
		return $this;
	}

	/**
	 * Add a file to be attached to the email
	 * 
	 * ~~~~~~
	 * $m = wireMail();
	 * $m->to('user@domain.com')->from('hello@world.com');
	 * $m->subject('Test attachment');
	 * $m->body('This is just a test of a file attachment');
	 * $m->attachment('/path/to/file.jpg'); 
	 * $m->send();
	 * ~~~~~~
	 * 
	 * #pw-advanced
	 *
	 * - Multiple calls will append attachments. 
	 * - To remove the supplied attachments, specify NULL as the value. 
	 * - Attachments may or may not be supported by 3rd party WireMail modules. 
	 *
	 * @param string $value Full path and filename of file attachment
	 * @param string $filename Optional different basename for file as it appears in the mail
	 * @return $this 
	 *
	 */
	public function attachment($value, $filename = '') {
		if(is_null($value)) {
			$this->mail['attachments'] = array();
		} else if(is_file($value)) { 
			$filename = $filename ?: basename($value);
			$this->mail['attachments'][$filename] = $value; 
		}
		return $this; 
	}

	/**
	 * Get the multipart boundary string for this email
	 * 
	 * @param string|bool $prefix Specify optional boundary prefix or boolean true to clear any existing stored boundary
	 * @return string
	 * 
	 */
	protected function multipartBoundary($prefix = '') {
		$boundary = parent::get('_multipartBoundary');
		if(empty($boundary) || $prefix === true) {
			$boundary = "==Multipart_Boundary_x" . md5(time()) . "x";
			parent::set('_multipartBoundary', $boundary);
		}
		if(is_string($prefix) && !empty($prefix)) {
			$boundary = str_replace("_Boundary_x", "_Boundary_{$prefix}_x", $boundary);
		}
		return $boundary;
	}
	
	/**
	 * Send the email
	 *
	 * Call this method only after you have specified at least the `subject`, `to` and `body`.
	 *
	 * #pw-notes This is the primary method that modules extending this class would want to replace.
	 *
	 * @return int Returns a positive number (indicating number of addresses emailed) or 0 on failure.
	 *
	 */
	public function ___send() {

		// prep header and body
		$this->multipartBoundary(true);
		$header = $this->renderMailHeader();
		$body = $this->renderMailBody();

		// adjust for the cases where people want to change RFC standard \r\n to just \n
		$newline = parent::get('newline');
		if(is_string($newline) && strlen($newline) && $newline !== "\r\n") {
			$body = str_replace("\r\n", $newline, $body);
			$header = str_replace("\r\n", $newline, $header);
		}
		
		// prep any additional PHP mail params
		$param = $this->wire('config')->phpMailAdditionalParameters;
		if(is_null($param)) $param = '';
		foreach($this->param as $value) {
			$param .= " $value";
		}

		// send email(s)
		$numSent = 0;
		$subject = $this->encodeSubject($this->subject);
		
		foreach($this->to as $to) {
			$toName = isset($this->mail['toName'][$to]) ? $this->mail['toName'][$to] : '';
			if($toName) $to = $this->bundleEmailAndName($to, $toName); // bundle to "User Name <user@example.com>"
			if($param) {
				if(@mail($to, $subject, $body, $header, $param)) $numSent++;
			} else {
				if(@mail($to, $subject, $body, $header)) $numSent++;
			}
		}

		return $numSent;
	}

	/**
	 * Render email header string
	 * 
	 * @return string
	 * 
	 */
	protected function renderMailHeader() {
		
		$settings = $this->wire()->config->wireMail;
		$from = $this->from;
		
		if(!strlen($from) && !empty($settings['from'])) $from = $settings['from'];
		if(!strlen($from)) $from = $this->wire('config')->adminEmail;
		if(!strlen($from)) $from = 'processwire@' . $this->wire('config')->httpHost;
		
		$header = "From: " . ($this->fromName ? $this->bundleEmailAndName($from, $this->fromName) : $from);

		foreach($this->header as $key => $value) {
			$header .= "\r\n$key: $value";
		}
		
		$boundary = $this->multipartBoundary();
		$header = trim($this->strReplace($header, $boundary)); 
		
		if($this->bodyHTML || count($this->attachments)) {
			$contentType = count($this->attachments) ? 'multipart/mixed' : 'multipart/alternative';
			$header .= 
				"\r\nMIME-Version: 1.0" . 
				"\r\nContent-Type: $contentType;\r\n  boundary=\"$boundary\"";
		} else {
			$header .= 
				"\r\nContent-Type: text/plain; charset=UTF-8" .
				"\r\nContent-Transfer-Encoding: quoted-printable"; 
		}
		
		return $header;
	}

	/**
	 * Render mail body 
	 * 
	 * @return string
	 * 
	 */
	protected function renderMailBody() {
		
		$boundary = $this->multipartBoundary(); 
		$subboundary = $this->multipartBoundary('alt');
	
		// don’t allow boundary to appear in visible portions of email
		$text = $this->strReplace($this->body, array($boundary, $subboundary)); 
		$html = $this->strReplace($this->bodyHTML, array($boundary, $subboundary));

		// if plain text only, return now
		if(empty($html) && !count($this->attachments)) return quoted_printable_encode($text);

		// if only HTML provided, generate text version from HTML
		if(!strlen($text) && strlen($html)) $text = $this->htmlToText($html);

		$body = 
			"This is a multi-part message in MIME format.\r\n\r\n" .
			"--$boundary\r\n";

		// Plain Text
		$textbody = 
			"Content-Type: text/plain; charset=\"utf-8\"\r\n" .
			"Content-Transfer-Encoding: quoted-printable\r\n\r\n" .
			quoted_printable_encode($text) . "\r\n\r\n";

		if($this->bodyHTML) {
			// HTML
			$htmlbody = 
				"Content-Type: text/html; charset=\"utf-8\"\r\n" .
				"Content-Transfer-Encoding: quoted-printable\r\n\r\n" .
				quoted_printable_encode($html) . "\r\n\r\n";

			if(count($this->attachments)) {
				// file attachments
				$textbody = $this->strReplace($textbody, $subboundary);
				$htmlbody = $this->strReplace($htmlbody, $subboundary);

				$body .= 
					"Content-Type: multipart/alternative;\r\n	boundary=\"$subboundary\"\r\n\r\n" .
					"--$subboundary\r\n" .
					$textbody .
					"--$subboundary\r\n" .
					$htmlbody .
					"--$subboundary--\r\n\r\n";
				
			} else {
				// no file attachments
				$body .= 
					$textbody .
					"--$boundary\r\n" .
					$htmlbody;
			}
			
		} else {
			// plain text
			$body .= $textbody;
		}

		if(count($this->attachments)) {
			$body .= $this->renderMailAttachments(); 
		}

		$body .= "--$boundary--\r\n";

		return $body;
	}	
	
	/**
	 * Render mail attachments string for placement in body
	 * 
	 * @return string
	 * 
	 */
	protected function renderMailAttachments() {
		$body = '';
		$boundary = $this->multipartBoundary();
		
		foreach($this->attachments as $filename => $file) {
			
			$filename = $this->wire('sanitizer')->text($filename, array(
				'maxLength' => 512,
				'truncateTail' => false, 
				'stripSpace' => '-',
				'stripQuotes' => true
			));
			
			if(stripos($filename, $boundary) !== false) continue;
			
			$content = file_get_contents($file);
			$content = chunk_split(base64_encode($content));
	
			if(stripos($content, $boundary) !== false) continue;
			
			$body .=
				"--$boundary\r\n" .
				"Content-Type: application/octet-stream; name=\"$filename\"\r\n" .
				"Content-Transfer-Encoding: base64\r\n" .
				"Content-Disposition: attachment; filename=\"$filename\"\r\n\r\n" .
				"$content\r\n\r\n";
		}
		
		return $body;
	}

	/**
	 * Recursive string replacement
	 * 
	 * This is better than using str_replace() because it handles cases where replacement
	 * results in the construction of a new $find that was not present in original $str.
	 * Note: this function ignores case. 
	 * 
	 * @param string $str
	 * @param string|array $find
	 * @param string $replace 
	 * @return string
	 * 
	 */
	protected function strReplace($str, $find, $replace = '') {
		if(!is_array($find)) $find = array($find);
		if(!is_string($str)) $str = (string) $str;
		foreach($find as $findStr) {
			if(is_array($findStr)) continue;
			while(stripos($str, $findStr) !== false) {
				$str = str_ireplace($findStr, $replace, $str);
			}
		}
		return $str;
	}

	/**
	 * Convert HTML mail body to TEXT mail body
	 * 
	 * @param string $html
	 * @return string
	 * 
	 */
	protected function ___htmlToText($html) {
		$text = $this->wire('sanitizer')->getTextTools()->markupToText($html);
		$text = str_replace("\n", "\r\n", $text); 
		$text = $this->strReplace($text, $this->multipartBoundary()); 
		return $text;
	}
	
	/**
	 * Encode a subject, use mbstring if available
	 * 
	 * #pw-advanced
	 *
	 * @param string $subject
	 * @return string
	 *
	 */
	public function encodeSubject($subject) {
		
		$boundary = $this->multipartBoundary();
		$subject = $this->strReplace($subject, $boundary);
		
		if(extension_loaded("mbstring")) {
			// Need to pass in the header name and subtract it afterwards,
			// otherwise the first line would grow too long
			return substr(mb_encode_mimeheader("Subject: $subject", 'UTF-8', 'Q', "\r\n"), 9);
		}

		$out = array();
		$isFirst = true;
		$n = 0;

		while(strlen($subject) > 0 && ++$n < 50) {
			$part = $this->findBestEncodePart($subject, 63, $isFirst);
			$out[] = $this->quotedPrintableString($part);
			$subject = substr($subject, strlen($part));
			$isFirst = false;
		}
		
		return implode("\r\n ", $out);
	}
	
	/**
	 * Tries to split the passed subject at a whitespace at or before $maxlen,
	 * falling back to a hard substr if none was found, and returns the
	 * left part.
	 *
	 * Makes sure that the quoted-printable encoded part is inside the 76 characters
	 * header limit (66 for first line that has the header name, minus a buffer
	 * of 2 characters for whitespace) given in rfc2047.
	 *
	 * @param string $input The subject to encode
	 * @param int $maxlen Maximum length of unencoded string, defaults to 63
	 * @param bool $isFirst Set to true for first line to account for the header name
	 * @return string
	 *
	 */
	protected function findBestEncodePart($input, $maxlen = 63, $isFirst = false) {
		$maxEffLen = $maxlen - ($isFirst ? 10 : 0);

		if(strlen($input) <= $maxEffLen) {
			$part = $input;
		} else if(strpos($input, " ") === FALSE || strrpos($input, " ") === FALSE || strpos($input, " ") > $maxEffLen) {
			// Force cutting of subject since there is no whitespace to break on
			$part = substr($input, $maxlen - $maxEffLen);
		} else {
			$searchstring = substr($input, 0, $maxEffLen);
			$lastpos = strrpos($searchstring, " ");
			$part = substr($input, 0, $lastpos);
		}

		if(strlen($this->quotedPrintableString($part)) > 74 - ($isFirst ? 10 : 0)) {
			return $this->findBestEncodePart($input, $maxlen - 1, $isFirst);
		}

		return $part;
	}
	
	/**
	 * Return the text quoted-printable encoded
	 *
	 * Uses short notation for charset and encoding suitable for email headers
	 * as laid out in rfc2047.
	 * 
	 * #pw-advanced
	 *
	 * @param string $text
	 * @return string
	 *
	 */
	public function quotedPrintableString($text) {
		return '=?utf-8?Q?' . quoted_printable_encode($text) . '?=';
	}

}
