Index: branches/5.2.x/core/kernel/utility/email_send.php =================================================================== --- branches/5.2.x/core/kernel/utility/email_send.php (revision 16198) +++ branches/5.2.x/core/kernel/utility/email_send.php (revision 16199) @@ -1,2087 +1,2150 @@ guessOptions = Array ( 'attachments' => Array (), 'inline_attachments' => Array (), 'text_part' => false, 'html_part' => false, ); // read SMTP server connection params from config $smtp_mapping = Array ('server' => 'Smtp_Server', 'port' => 'Smtp_Port'); if ($this->Application->ConfigValue('Smtp_Authenticate')) { $smtp_mapping['username'] = 'Smtp_User'; $smtp_mapping['password'] = 'Smtp_Pass'; } foreach ($smtp_mapping as $smtp_name => $config_name) { $this->smtpParams[$smtp_name] = $this->Application->ConfigValue($config_name); } $this->smtpParams['use_auth'] = isset($this->smtpParams['username']) ? true : false; $this->smtpParams['localhost'] = 'localhost'; // The value to give when sending EHLO or HELO. $this->sendMethod = $this->smtpParams['server'] && $this->smtpParams['port'] ? 'SMTP' : 'Mail'; if ($this->sendMethod == 'SMTP') { // create connection object if we will use SMTP $this->smtpSocket = $this->Application->makeClass('Socket'); } $this->SetCharset(null, true); } /** * Returns new message id header by sender's email address * * @param string $email_address email address * @return string */ function GenerateMessageID($email_address) { list ($micros, $seconds) = explode(' ', microtime()); list ($user, $domain) = explode('@', $email_address, 2); $message_id = strftime('%Y%m%d%H%M%S', $seconds).substr($micros, 1, 5).'.'.preg_replace('/[^A-Za-z]+/', '-', $user).'@'.$domain; $this->SetHeader('Message-ID', '<'.$message_id.'>'); } /** * Returns extension of given filename * * @param string $filename * @return string */ function GetFilenameExtension($filename) { $last_dot = mb_strrpos($filename, '.'); return $last_dot !== false ? mb_substr($filename, $last_dot + 1) : ''; } /** * Creates boundary for part by number (only if it's missing) * * @param int $part_number * */ function CreatePartBoundary($part_number) { $part =& $this->parts[$part_number]; if (!isset($part['BOUNDARY'])) { $part['BOUNDARY'] = md5(uniqid($part_number.time())); } } /** * Returns ready to use headers associative array of any message part by it's number * * @param int $part_number * @return Array */ function GetPartHeaders($part_number) { $part =& $this->parts[$part_number]; if (!isset($part['Content-Type'])) { return $this->SetError('MISSING_CONTENT_TYPE'); } $full_type = strtolower($part['Content-Type']); list ($type, $sub_type) = explode('/', $full_type); $headers['Content-Type'] = $full_type; switch ($type) { case 'text': case 'image': case 'audio': case 'video': case 'application': case 'message': // 1. update content-type header if (isset($part['CHARSET'])) { $headers['Content-Type'] .= '; charset='.$part['CHARSET']; } if (isset($part['NAME'])) { $headers['Content-Type'] .= '; name="'.$part['NAME'].'"'; } // 2. set content-transfer-encoding header if (isset($part['Content-Transfer-Encoding'])) { $headers['Content-Transfer-Encoding'] = $part['Content-Transfer-Encoding']; } // 3. set content-disposition header if (isset($part['DISPOSITION']) && $part['DISPOSITION']) { $headers['Content-Disposition'] = $part['DISPOSITION']; if (isset($part['NAME'])) { $headers['Content-Disposition'] .= '; filename="'.$part['NAME'].'"'; } } break; case 'multipart': switch ($sub_type) { case 'alternative': case 'related': case 'mixed': case 'parallel': $this->CreatePartBoundary($part_number); $headers['Content-Type'] .= '; boundary="'.$part['BOUNDARY'].'"'; break; default: return $this->SetError('INVALID_MULTIPART_SUBTYPE', Array($sub_type)); } break; default: return $this->SetError('INVALID_CONTENT_TYPE', Array($full_type)); } // set content-id if any if (isset($part['Content-ID'])) { $headers['Content-ID'] = '<'.$part['Content-ID'].'>'; } return $headers; } function GetPartBody($part_number) { $part =& $this->parts[$part_number]; if (!isset($part['Content-Type'])) { return $this->SetError('MISSING_CONTENT_TYPE'); } $full_type = strtolower($part['Content-Type']); list ($type, $sub_type) = explode('/', $full_type); $body = ''; switch ($type) { // compose text/binary content case 'text': case 'image': case 'audio': case 'video': case 'application': case 'message': // 1. get content of part if (isset($part['FILENAME'])) { // content provided via absolute path to content containing file $filename = $part['FILENAME']; $file_size = filesize($filename); $body = file_get_contents($filename); if ($body === false) { return $this->SetError('FILE_PART_OPEN_ERROR', Array($filename)); } $actual_size = strlen($body); if (($file_size === false || $actual_size > $file_size) && get_magic_quotes_runtime()) { $body = stripslashes($body); } if ($file_size !== false && $actual_size != $file_size) { return $this->SetError('FILE_PART_DATA_ERROR', Array($filename)); } } else { // content provided directly as one of part keys if (!isset($part['DATA'])) { return $this->SetError('FILE_PART_DATA_MISSING'); } $body =& $part['DATA']; } // 2. get part transfer encoding $encoding = isset($part['Content-Transfer-Encoding']) ? strtolower($part['Content-Transfer-Encoding']) : ''; if (!in_array($encoding, Array ('', 'base64', 'quoted-printable', '7bit'))) { return $this->SetError('INVALID_ENCODING', Array($encoding)); } if ($encoding == 'base64') { // split base64 encoded text by 76 symbols at line (MIME requirement) $body = chunk_split( base64_encode($body) ); } break; case 'multipart': // compose multipart message switch ($sub_type) { case 'alternative': case 'related': case 'mixed': case 'parallel': $this->CreatePartBoundary($part_number); $boundary = $this->line_break.'--'.$part['BOUNDARY']; foreach ($part['PARTS'] as $multipart_number) { $body .= $boundary.$this->line_break; $part_headers = $this->GetPartHeaders($multipart_number); if ($part_headers === false) { // some of sub-part headers were invalid return false; } foreach ($part_headers as $header_name => $header_value) { $body .= $header_name.': '.$header_value.$this->line_break; } $part_body = $this->GetPartBody($multipart_number); if ($part_body === false) { // part body was invalid return false; } $body .= $this->line_break.$part_body; } $body .= $boundary.'--'.$this->line_break; break; default: return $this->SetError('INVALID_MULTIPART_SUBTYPE', Array($sub_type)); } break; default: return $this->SetError('INVALID_CONTENT_TYPE', Array($full_type)); } return $body; } /** * Applies quoted-printable encoding to specified text * * @param string $text * @param string $header_charset * @param int $break_lines * @return unknown */ function QuotedPrintableEncode($text, $header_charset = '', $break_lines = 1) { $ln = strlen($text); $h = strlen($header_charset) > 0; if ($h) { $s = Array ( '=' => 1, '?' => 1, '_' => 1, '(' => 1, ')' => 1, '<' => 1, '>' => 1, '@' => 1, ',' => 1, ';' => 1, '"' => 1, '\\' => 1, /* '/' => 1, '[' => 1, ']' => 1, ':' => 1, '.' => 1, */ ); $b = $space = $break_lines = 0; for ($i = 0; $i < $ln; $i++) { if (isset($s[$text[$i]])) { $b = 1; break; } switch ($o = ord($text[$i])) { case 9: case 32: $space = $i + 1; $b = 1; break 2; case 10: case 13: break 2; default: if ($o < 32 || $o > 127) { $b = 1; break 2; } } } if($i == $ln) { return $text; } if ($space > 0) { return substr($text, 0, $space).($space < $ln ? $this->QuotedPrintableEncode(substr($text, $space), $header_charset, 0) : ''); } } for ($w = $e = '', $n = 0, $l = 0, $i = 0; $i < $ln; $i++) { $c = $text[$i]; $o = ord($c); $en = 0; switch ($o) { case 9: case 32: if (!$h) { $w = $c; $c = ''; } else { if ($b) { if ($o == 32) { $c = '_'; } else { $en = 1; } } } break; case 10: case 13: if (strlen($w)) { if ($break_lines && $l + 3 > 75) { $e .= '='.$this->line_break; $l = 0; } $e .= sprintf('=%02X', ord($w)); $l += 3; $w = ''; } $e .= $c; if ($h) { $e .= "\t"; } $l = 0; continue 2; case 46: case 70: case 102: $en = (!$h && ($l == 0 || $l + 1 > 75)); break; default: if ($o > 127 || $o < 32 || !strcmp($c, '=')) { $en = 1; } elseif ($h && isset($s[$c])) { $en = 1; } break; } if (strlen($w)) { if ($break_lines && $l + 1 > 75) { $e .= '='.$this->line_break; $l = 0; } $e .= $w; $l++; $w = ''; } if (strlen($c)) { if ($en) { $c = sprintf('=%02X', $o); $el = 3; $n = 1; $b = 1; } else { $el = 1; } if ($break_lines && $l + $el > 75) { $e .= '='.$this->line_break; $l = 0; } $e .= $c; $l += $el; } } if (strlen($w)) { if ($break_lines && $l + 3 > 75) { $e .= '='.$this->line_break; } $e .= sprintf('=%02X', ord($w)); } return $h && $n ? '=?'.$header_charset.'?q?'.$e.'?=' : $e; } /** * Sets message header + encodes is by quoted-printable using charset specified * * @param string $name * @param string $value * @param string $encoding_charset */ function SetHeader($name, $value, $encoding_charset = '') { if ($encoding_charset) { // actually for headers base64 method may give shorter result $value = $this->QuotedPrintableEncode($value, $encoding_charset); } $this->headers[$name] = $value; } /** * Sets header + automatically encodes it using default charset * * @param string $name * @param string $value */ function SetEncodedHeader($name, $value) { $this->SetHeader($name, $value, $this->charset); } /** * Sets header which value is email and username +autoencode * * @param string $header * @param string $address * @param string $name */ function SetEncodedEmailHeader($header, $address, $name) { $this->SetHeader($header, $this->QuotedPrintableEncode($name, $this->charset) . ' <' . $address . '>'); } function SetMultipleEncodedEmailHeader($header, $addresses) { $value = ''; foreach ($addresses as $name => $address) { $value .= $this->QuotedPrintableEncode($name, $this->charset) . ' <' . $address . '>, '; } $this->SetHeader($header, substr($value, 0, -2)); } /** * Adds new part to message and returns it's number * * @param Array $part_definition * @param int|bool $part_number number of new part * @return int */ function AddPart(&$part_definition, $part_number = false) { $part_number = $part_number !== false ? $part_number : count($this->parts); $this->parts[$part_number] =& $part_definition; return $part_number; } /** * Returns text version of HTML document * * @param string $html * @param bool $keep_inp_tags * @return string */ function ConvertToText($html, $keep_inp_tags = false) { if ( $keep_inp_tags && preg_match_all('/(<[\\/]?)inp2:([^>]*?)([\\/]?>)/s', $html, $regs) ) { $found_tags = Array (); foreach ($regs[0] as $index => $tag) { $tag_placeholder = '%' . md5($index . ':' . $tag) . '%'; $found_tags[$tag_placeholder] = $tag; // we can have duplicate tags -> replace only 1st occurrence (str_replace can't do that) $html = preg_replace('/' . preg_quote($tag, '/') . '/', $tag_placeholder, $html, 1); } $html = $this->_convertToText($html); foreach ($found_tags as $tag_placeholder => $tag) { $html = str_replace($tag_placeholder, $tag, $html); } return $html; } return $this->_convertToText($html); } /** * Returns text version of HTML document * * @param string $html * @return string */ protected function _convertToText($html) { $search = Array ( "'(<\/td>.*)[\r\n]+(.*[\r\n]{0,2})|(<\/p>)|(<\/div>)|(<\/tr>)'i", "'(.*?)'si", "''si", "'(.*?)'si", "''si", // "'^[\s\n\r\t]+'", //strip all spacers & newlines in the begin of document // "'[\s\n\r\t]+$'", //strip all spacers & newlines in the end of document "'&(quot|#34);'i", "'&(amp|#38);'i", "'&(lt|#60);'i", "'&(gt|#62);'i", "'&(nbsp|#160);'i", "'&(iexcl|#161);'i", "'&(cent|#162);'i", "'&(pound|#163);'i", "'&(copy|#169);'i", "'&#(\d+);'e" ); $replace = Array ( "\\1\t\\2", "\n", "", "", "", "", // "", // "", "\"", "&", "<", ">", " ", $this->_safeCharEncode(161), $this->_safeCharEncode(162), $this->_safeCharEncode(163), $this->_safeCharEncode(169), "\$this->_safeCharEncode(\\1)" ); return strip_tags( preg_replace ($search, $replace, $html) ); } /** * Returns symbols, that corresponds given ASCII code and also encodes it into proper charset * * @param int $ascii * @return string * @access protected */ protected function _safeCharEncode($ascii) { return mb_convert_encoding(chr($ascii), $this->charset, 'ISO-8859-1'); } /** * Add text OR html part to message (optionally encoded) * * @param string $text part's text * @param bool $is_html this html part or not * @param bool $encode encode message using quoted-printable encoding * * @return int number of created part */ function CreateTextHtmlPart($text, $is_html = false, $encode = true) { if ($is_html) { // if adding HTML part, then create plain-text part too $this->CreateTextHtmlPart($this->ConvertToText($text)); } // in case if text is from $_REQUEST, then line endings are "\r\n", but we need "\n" here $text = str_replace("\r\n", "\n", $text); // possible case $text = str_replace("\r", "\n", $text); // impossible case, but just in case replace this too $definition = Array ( 'Content-Type' => $is_html ? 'text/html' : 'text/plain', 'CHARSET' => $this->charset, 'DATA' => $encode ? $this->QuotedPrintableEncode($text) : $text, ); if ($encode) { $definition['Content-Transfer-Encoding'] = 'quoted-printable'; } $guess_name = $is_html ? 'html_part' : 'text_part'; $part_number = $this->guessOptions[$guess_name] !== false ? $this->guessOptions[$guess_name] : false; $part_number = $this->AddPart($definition, $part_number); $this->guessOptions[$guess_name] = $part_number; return $part_number; } /** * Adds attachment part to message * * @param string $file name of the file with attachment body * @param string $attach_name name for attachment (name of file is used, when not specified) * @param string $content_type content type for attachment * @param string $content body of file to be attached * @param bool $inline is attachment inline or not * * @return int number of created part */ function AddAttachment($file = '', $attach_name = '', $content_type = '', $content = '', $inline = false) { $definition = Array ( 'Disposition' => $inline ? 'inline' : 'attachment', 'Content-Type' => $content_type ? $content_type : 'automatic/name', ); if ($file) { // filename of attachment given $definition['FileName'] = $file; } if ($attach_name) { // name of attachment given $definition['Name'] = $attach_name; } if ($content) { // attachment data is given $definition['Data'] = $content; } $definition =& $this->GetFileDefinition($definition); $part_number = $this->AddPart($definition); if ($inline) { // it's inline attachment and needs content-id to be addressed by in message $this->parts[$part_number]['Content-ID'] = md5(uniqid($part_number.time())).'.'.$this->GetFilenameExtension($attach_name ? $attach_name : $file); } $this->guessOptions[$inline ? 'inline_attachments' : 'attachments'][] = $part_number; return $part_number; } /** * Adds another MIME message as attachment to message being composed * * @param string $file name of the file with attachment body * @param string $attach_name name for attachment (name of file is used, when not specified) * @param string $content body of file to be attached * * @return int number of created part */ function AddMessageAttachment($file = '', $attach_name = '', $content = '') { $part_number = $this->AddAttachment($file, $attach_name, 'message/rfc822', $content, true); unset($this->parts[$part_number]['Content-ID']); // messages don't have content-id, but have inline disposition return $part_number; } /** * Creates multipart of specified type and returns it's number * * @param Array $part_numbers * @param string $multipart_type = {alternative,related,mixed,paralell} * @return int */ function CreateMultipart($part_numbers, $multipart_type) { $types = Array ('alternative', 'related' , 'mixed', 'paralell'); if (!in_array($multipart_type, $types)) { return $this->SetError('INVALID_MULTIPART_SUBTYPE', Array($multipart_type)); } $definition = Array ( 'Content-Type' => 'multipart/'.$multipart_type, 'PARTS' => $part_numbers, ); return $this->AddPart($definition); } /** * Creates missing content-id header for inline attachments * * @param int $part_number */ function CreateContentID($part_number) { $part =& $this->parts[$part_number]; if (!isset($part['Content-ID']) && $part['DISPOSITION'] == 'inline') { $part['Content-ID'] = md5(uniqid($part_number.time())).'.'.$this->GetFilenameExtension($part['NAME']); } } /** * Returns attachment part based on file used in attachment * * @param Array $file * @return Array */ function &GetFileDefinition ($file) { $name = ''; if (isset($file['Name'])) { // if name is given directly, then use it $name = $file['Name']; } else { // auto-guess attachment name based on source filename $name = isset($file['FileName']) ? basename($file['FileName']) : ''; } if (!$name || (!isset($file['FileName']) && !isset($file['Data']))) { // filename not specified || no filename + no direct file content return $this->SetError('MISSING_FILE_DATA'); } $encoding = 'base64'; if (isset($file['Content-Type'])) { $content_type = $file['Content-Type']; list ($type, $sub_type) = explode('/', $content_type); switch ($type) { case 'text': case 'image': case 'audio': case 'video': case 'application': break; case 'message': $encoding = '7bit'; break; case 'automatic': if (!$name) { return $this->SetError('MISSING_FILE_NAME'); } $this->guessContentType($name, $content_type, $encoding); break; default: return $this->SetError('INVALID_CONTENT_TYPE', Array($content_type)); } } else { // encoding not passed in file part, then assume, that it's binary $content_type = 'application/octet-stream'; } $definition = Array ( 'Content-Type' => $content_type, 'Content-Transfer-Encoding' => $encoding, 'NAME' => $name, // attachment name ); if (isset($file['Disposition'])) { $disposition = strtolower($file['Disposition']); if ($disposition == 'inline' || $disposition == 'attachment') { // valid disposition header value $definition['DISPOSITION'] = $file['Disposition']; } else { return $this->SetError('INVALID_DISPOSITION', Array($file['Disposition'])); } } if (isset($file['FileName'])) { $definition['FILENAME'] = $file['FileName']; } elseif (isset($file['Data'])) { $definition['DATA'] =& $file['Data']; } return $definition; } /** * Returns content-type based on filename extension * * @param string $filename * @param string $content_type * @param string $encoding * * @todo Regular expression used is not completely finished, that's why if extension used for * comparing in some other extension (from list) part, that partial match extension will be returned. * Because of two extension that begins with same 2 letters always belong to same content type * this unfinished regular expression still gives correct result in any case. */ function guessContentType($filename, &$content_type, &$encoding) { $content_type = kUtil::mimeContentTypeByExtension($filename); if ( mb_strtolower($this->GetFilenameExtension($filename)) == 'eml' ) { $encoding = '7bit'; } } /** * Using guess options combines all added parts together and returns combined part number * * @return int */ function PrepareMessageBody() { if ($this->bodyPartNumber === false) { $part_number = false; // number of generated body part // 1. set text content of message if ($this->guessOptions['text_part'] !== false && $this->guessOptions['html_part'] !== false) { // text & html parts present -> compose into alternative part $parts = Array ( $this->guessOptions['text_part'], $this->guessOptions['html_part'], ); $part_number = $this->CreateMultipart($parts, 'alternative'); } elseif ($this->guessOptions['text_part'] !== false) { // only text part is defined, then leave as is $part_number = $this->guessOptions['text_part']; } if ($part_number === false) { return $this->SetError('MESSAGE_TEXT_MISSING'); } // 2. if inline attachments found, then create related multipart from text & inline attachments if ($this->guessOptions['inline_attachments']) { $parts = array_merge(Array($part_number), $this->guessOptions['inline_attachments']); $part_number = $this->CreateMultipart($parts, 'related'); } // 3. if normal attachments found, then create mixed multipart from text & attachments if ($this->guessOptions['attachments']) { $parts = array_merge(Array($part_number), $this->guessOptions['attachments']); $part_number = $this->CreateMultipart($parts, 'mixed'); } $this->bodyPartNumber = $part_number; } return $this->bodyPartNumber; } /** * Returns message headers and body part (by reference in parameters) * * @param Array $message_headers * @param string $message_body * @return bool */ function GetHeadersAndBody(&$message_headers, &$message_body) { $part_number = $this->PrepareMessageBody(); if ($part_number === false) { return $this->SetError('MESSAGE_COMPOSE_ERROR'); } $message_headers = $this->GetPartHeaders($part_number); // join message headers and body headers $message_headers = array_merge($this->headers, $message_headers); $message_headers['MIME-Version'] = '1.0'; if ($this->mailerName) { $message_headers['X-Mailer'] = $this->mailerName; } $this->GenerateMessageID($message_headers['From']); $valid_headers = $this->ValidateHeaders($message_headers); if ($valid_headers) { // set missing headers from existing $from_headers = Array ('Reply-To', 'Errors-To'); foreach ($from_headers as $header_name) { if (!isset($message_headers[$header_name])) { $message_headers[$header_name] = $message_headers['From']; } } $message_body = $this->GetPartBody($part_number); return true; } return false; } /** * Checks that all required headers are set and not empty * * @param Array $message_headers * @return bool */ function ValidateHeaders($message_headers) { $from = isset($message_headers['From']) ? $message_headers['From'] : ''; if (!$from) { return $this->SetError('HEADER_MISSING', Array('From')); } if (!isset($message_headers['To'])) { return $this->SetError('HEADER_MISSING', Array('To')); } if (!isset($message_headers['Subject'])) { return $this->SetError('HEADER_MISSING', Array('Subject')); } return true; } /** * Returns full message source (headers + body) for sending to SMTP server * * @return string */ function GetMessage() { $composed = $this->GetHeadersAndBody($message_headers, $message_body); if ($composed) { // add headers to resulting message $message = ''; foreach ($message_headers as $header_name => $header_value) { $message .= $header_name.': '.$header_value.$this->line_break; } // add message body $message .= $this->line_break.$message_body; return $message; } return false; } /** * Sets just happened error code * * @param string $code * @param Array $params additional error params * @param bool $fatal * @return bool */ function SetError($code, $params = null, $fatal = true) { $error_msgs = Array ( 'MAIL_NOT_FOUND' => 'the mail() function is not available in this PHP installation', 'MISSING_CONTENT_TYPE' => 'it was added a part without Content-Type: defined', 'INVALID_CONTENT_TYPE' => 'Content-Type: %s not yet supported', 'INVALID_MULTIPART_SUBTYPE' => 'multipart Content-Type sub_type %s not yet supported', 'FILE_PART_OPEN_ERROR' => 'could not open part file %s', 'FILE_PART_DATA_ERROR' => 'the length of the file that was read does not match the size of the part file %s due to possible data corruption', 'FILE_PART_DATA_MISSING' => 'it was added a part without a body PART', 'INVALID_ENCODING' => '%s is not yet a supported encoding type', 'MISSING_FILE_DATA' => 'file part data is missing', 'MISSING_FILE_NAME' => 'it is not possible to determine content type from the name', 'INVALID_DISPOSITION' => '%s is not a supported message part content disposition', 'MESSAGE_TEXT_MISSING' => 'text part of message was not defined', 'MESSAGE_COMPOSE_ERROR' => 'unknown message composing error', 'HEADER_MISSING' => 'header %s is required', // SMTP errors 'INVALID_COMMAND' => 'Commands cannot contain newlines', 'CONNECTION_TERMINATED' => 'Connection was unexpectedly closed', 'HELO_ERROR' => 'HELO was not accepted: %s', 'AUTH_METHOD_NOT_SUPPORTED' => '%s is not a supported authentication method', 'AUTH_METHOD_NOT_IMPLEMENTED' => '%s is not a implemented authentication method', ); if (!is_array($params)) { $params = Array (); } $error_msg = 'mail error: ' . vsprintf($error_msgs[$code], $params); if ($fatal) { throw new Exception($error_msg); } else { if ( $this->Application->isDebugMode() ) { $this->Application->Debugger->appendTrace(); } trigger_error($error_msg, E_USER_WARNING); } return false; } /** * Simple method of message sending * * @param string $from_email * @param string $to_email * @param string $subject * @param string $from_name * @param string $to_name */ function Send($from_email, $to_email, $subject, $from_name = '', $to_name = '') { $this->SetSubject($subject); $this->SetFrom($from_email, trim($from_name) ? trim($from_name) : $from_email); if (!isset($this->headers['Return-Path'])) { $this->SetReturnPath($from_email); } $this->SetTo($to_email, $to_name ? $to_name : $to_email); return $this->Deliver(); } /** * Prepares class for sending another message * */ function Clear() { $this->headers = Array (); $this->bodyPartNumber = false; $this->parts = Array (); $this->guessOptions = Array ( 'attachments' => Array (), 'inline_attachments' => Array (), 'text_part' => false, 'html_part' => false, ); $this->SetCharset(null, true); $this->_logData = Array (); } /** * Sends message via php mail function * * @param Array $message_headers * @param string $body * * @return bool */ function SendMail($message_headers, &$body) { if (!function_exists('mail')) { return $this->SetError('MAIL_NOT_FOUND'); } $to = $message_headers['To']; $subject = $message_headers['Subject']; $return_path = $message_headers['Return-Path']; unset($message_headers['To'], $message_headers['Subject']); $headers = ''; $header_separator = $this->Application->ConfigValue('MailFunctionHeaderSeparator') == 1 ? "\n" : "\r\n"; foreach ($message_headers as $header_name => $header_value) { $headers .= $header_name.': '.$header_value.$header_separator; } if ($return_path) { if (kUtil::constOn('SAFE_MODE') || (defined('PHP_OS') && substr(PHP_OS, 0, 3) == 'WIN')) { // safe mode restriction OR is windows $return_path = ''; } } return mail($to, $subject, $body, $headers, $return_path ? '-f'.$return_path : null); } /** * Sends message via SMTP server * * @param Array $message_headers * @param string $message_body * @return bool */ function SendSMTP($message_headers, &$message_body) { if (!$this->SmtpConnect()) { return false; } $from = $this->ExtractRecipientEmail($message_headers['From']); if (!$this->SmtpSetFrom($from)) { return false; } $recipients = ''; $recipient_headers = Array ('To', 'Cc', 'Bcc'); foreach ($recipient_headers as $recipient_header) { if (isset($message_headers[$recipient_header])) { $recipients .= ' '.$message_headers[$recipient_header]; } } $recipients_accepted = 0; $recipients = $this->ExtractRecipientEmail($recipients, true); foreach ($recipients as $recipient) { if ($this->SmtpAddTo($recipient)) { $recipients_accepted++; } } if ($recipients_accepted == 0) { // none of recipients were accepted return false; } $headers = ''; foreach ($message_headers as $header_name => $header_value) { $headers .= $header_name.': '.$header_value.$this->line_break; } if (!$this->SmtpSendMessage($headers . "\r\n" . $message_body)) { return false; } $this->SmtpDisconnect(); return true; } /** * Send a command to the server with an optional string of * arguments. A carriage return / linefeed (CRLF) sequence will * be appended to each command string before it is sent to the * SMTP server. * * @param string $command The SMTP command to send to the server. * @param string $args A string of optional arguments to append to the command. * * @return bool * */ function SmtpSendCommand($command, $args = '') { if (!empty($args)) { $command .= ' ' . $args; } if (strcspn($command, "\r\n") !== strlen($command)) { return $this->SetError('INVALID_COMMAND'); } return $this->smtpSocket->write($command . "\r\n") === false ? false : true; } /** * Read a reply from the SMTP server. The reply consists of a response code and a response message. * * @param mixed $valid The set of valid response codes. These may be specified as an array of integer values or as a single integer value. * * @return bool * */ function SmtpParseResponse($valid) { $this->smtpResponceCode = -1; $this->smtpRespoceArguments = array(); while ($line = $this->smtpSocket->readLine()) { // If we receive an empty line, the connection has been closed. if (empty($line)) { $this->SmtpDisconnect(); return $this->SetError('CONNECTION_TERMINATED', null, false); } // Read the code and store the rest in the arguments array. $code = substr($line, 0, 3); $this->smtpRespoceArguments[] = trim(substr($line, 4)); // Check the syntax of the response code. if (is_numeric($code)) { $this->smtpResponceCode = (int)$code; } else { $this->smtpResponceCode = -1; break; } // If this is not a multiline response, we're done. if (substr($line, 3, 1) != '-') { break; } } // Compare the server's response code with the valid code. if (is_int($valid) && ($this->smtpResponceCode === $valid)) { return true; } // If we were given an array of valid response codes, check each one. if (is_array($valid)) { foreach ($valid as $valid_code) { if ($this->smtpResponceCode === $valid_code) { return true; } } } return false; } /** * Attempt to connect to the SMTP server. * * @param int $timeout The timeout value (in seconds) for the socket connection. * @param bool $persistent Should a persistent socket connection be used ? * * @return bool * */ function SmtpConnect($timeout = null, $persistent = false) { $result = $this->smtpSocket->connect($this->smtpParams['server'], $this->smtpParams['port'], $persistent, $timeout); if (!$result) { return false; } if ($this->SmtpParseResponse(220) === false) { return false; } elseif ($this->SmtpNegotiate() === false) { return false; } if ($this->smtpParams['use_auth']) { $result = $this->SmtpAuthentificate($this->smtpParams['username'], $this->smtpParams['password']); if (!$result) { // authentification failed return false; } } return true; } /** * Attempt to disconnect from the SMTP server. * * @return bool */ function SmtpDisconnect() { if ($this->SmtpSendCommand('QUIT') === false) { return false; } elseif ($this->SmtpParseResponse(221) === false) { return false; } return $this->smtpSocket->disconnect(); } /** * Attempt to send the EHLO command and obtain a list of ESMTP * extensions available, and failing that just send HELO. * * @return bool */ function SmtpNegotiate() { if (!$this->SmtpSendCommand('EHLO', $this->smtpParams['localhost'])) { return false; } if (!$this->SmtpParseResponse(250)) { // If we receive a 503 response, we're already authenticated. if ($this->smtpResponceCode === 503) { return true; } // If the EHLO failed, try the simpler HELO command. if (!$this->SmtpSendCommand('HELO', $this->smtpParams['localhost'])) { return false; } if (!$this->SmtpParseResponse(250)) { return $this->SetError('HELO_ERROR', Array($this->smtpResponceCode), false); } return true; } foreach ($this->smtpRespoceArguments as $argument) { $verb = strtok($argument, ' '); $arguments = substr($argument, strlen($verb) + 1, strlen($argument) - strlen($verb) - 1); $this->smtpFeatures[$verb] = $arguments; } return true; } /** * Attempt to do SMTP authentication. * * @param string $uid The userid to authenticate as. * @param string $pwd The password to authenticate with. * @param string $method The requested authentication method. If none is specified, the best supported method will be used. * * @return bool */ function SmtpAuthentificate($uid, $pwd , $method = '') { if (empty($this->smtpFeatures['AUTH'])) { // server doesn't understand AUTH command, then don't authentificate return true; } $available_methods = explode(' ', $this->smtpFeatures['AUTH']); // methods supported by SMTP server if (empty($method)) { foreach ($this->smtpAuthMethods as $supported_method) { // check if server supports methods, that we have implemented if (in_array($supported_method, $available_methods)) { $method = $supported_method; break; } } } else { $method = strtoupper($method); } if (!in_array($method, $available_methods)) { // coosen method is not supported by server return $this->SetError('AUTH_METHOD_NOT_SUPPORTED', Array($method)); } switch ($method) { case 'CRAM-MD5': $result = $this->_authCRAM_MD5($uid, $pwd); break; case 'LOGIN': $result = $this->_authLogin($uid, $pwd); break; case 'PLAIN': $result = $this->_authPlain($uid, $pwd); break; default: return $this->SetError('AUTH_METHOD_NOT_IMPLEMENTED', Array($method)); break; } return $result; } /** * Function which implements HMAC MD5 digest * * @param string $key The secret key * @param string $data The data to protect * @return string The HMAC MD5 digest */ function _HMAC_MD5($key, $data) { if (strlen($key) > 64) { $key = pack('H32', md5($key)); } if (strlen($key) < 64) { $key = str_pad($key, 64, chr(0)); } $k_ipad = substr($key, 0, 64) ^ str_repeat(chr(0x36), 64); $k_opad = substr($key, 0, 64) ^ str_repeat(chr(0x5C), 64); $inner = pack('H32', md5($k_ipad . $data)); $digest = md5($k_opad . $inner); return $digest; } /** * Authenticates the user using the CRAM-MD5 method. * * @param string $uid The userid to authenticate as. * @param string $pwd The password to authenticate with. * * @return bool */ function _authCRAM_MD5($uid, $pwd) { if (!$this->SmtpSendCommand('AUTH', 'CRAM-MD5')) { return false; } // 334: Continue authentication request if (!$this->SmtpParseResponse(334)) { // 503: Error: already authenticated return $this->smtpResponceCode === 503 ? true : false; } $challenge = base64_decode($this->smtpRespoceArguments[0]); $auth_str = base64_encode($uid . ' ' . $this->_HMAC_MD5($pwd, $challenge)); if (!$this->SmtpSendCommand($auth_str)) { return false; } // 235: Authentication successful if (!$this->SmtpParseResponse(235)) { return false; } return true; } /** * Authenticates the user using the LOGIN method. * * @param string $uid The userid to authenticate as. * @param string $pwd The password to authenticate with. * * @return bool */ function _authLogin($uid, $pwd) { if (!$this->SmtpSendCommand('AUTH', 'LOGIN')) { return false; } // 334: Continue authentication request if (!$this->SmtpParseResponse(334)) { // 503: Error: already authenticated return $this->smtpResponceCode === 503 ? true : false; } if (!$this->SmtpSendCommand(base64_encode($uid))) { return false; } // 334: Continue authentication request if (!$this->SmtpParseResponse(334)) { return false; } if (!$this->SmtpSendCommand(base64_encode($pwd))) { return false; } // 235: Authentication successful if (!$this->SmtpParseResponse(235)) { return false; } return true; } /** * Authenticates the user using the PLAIN method. * * @param string $uid The userid to authenticate as. * @param string $pwd The password to authenticate with. * * @return bool */ function _authPlain($uid, $pwd) { if (!$this->SmtpSendCommand('AUTH', 'PLAIN')) { return false; } // 334: Continue authentication request if (!$this->SmtpParseResponse(334)) { // 503: Error: already authenticated return $this->smtpResponceCode === 503 ? true : false; } $auth_str = base64_encode(chr(0) . $uid . chr(0) . $pwd); if (!$this->SmtpSendCommand($auth_str)) { return false; } // 235: Authentication successful if (!$this->SmtpParseResponse(235)) { return false; } return true; } /** * Send the MAIL FROM: command. * * @param string $sender The sender (reverse path) to set. * @param string $params String containing additional MAIL parameters, such as the NOTIFY flags defined by RFC 1891 or the VERP protocol. * * @return bool */ function SmtpSetFrom($sender, $params = null) { $args = "FROM:<$sender>"; if (is_string($params)) { $args .= ' ' . $params; } if (!$this->SmtpSendCommand('MAIL', $args)) { return false; } if (!$this->SmtpParseResponse(250)) { return false; } return true; } /** * Send the RCPT TO: command. * * @param string $recipient The recipient (forward path) to add. * @param string $params String containing additional RCPT parameters, such as the NOTIFY flags defined by RFC 1891. * * @return bool */ function SmtpAddTo($recipient, $params = null) { $args = "TO:<$recipient>"; if (is_string($params)) { $args .= ' ' . $params; } if (!$this->SmtpSendCommand('RCPT', $args)) { return false; } if (!$this->SmtpParseResponse(array(250, 251))) { return false; } return true; } /** * Send the DATA command. * * @param string $data The message body to send. * * @return bool */ function SmtpSendMessage($data) { /* RFC 1870, section 3, subsection 3 states "a value of zero * indicates that no fixed maximum message size is in force". * Furthermore, it says that if "the parameter is omitted no * information is conveyed about the server's fixed maximum * message size". */ if (isset($this->smtpFeatures['SIZE']) && ($this->smtpFeatures['SIZE'] > 0)) { if (strlen($data) >= $this->smtpFeatures['SIZE']) { $this->SmtpDisconnect(); return $this->SetError('Message size excedes the server limit', null, false); } } // Quote the data based on the SMTP standards // Change Unix (\n) and Mac (\r) linefeeds into Internet-standard CRLF (\r\n) linefeeds. $data = preg_replace(Array('/(?SmtpSendCommand('DATA')) { return false; } if (!$this->SmtpParseResponse(354)) { return false; } if ($this->smtpSocket->write($data . "\r\n.\r\n") === false) { return false; } if (!$this->SmtpParseResponse(250)) { return false; } return true; } /** * Sets global charset for every message part * * @param string $charset * @param bool $is_system set charset to default for current language */ function SetCharset($charset, $is_system = false) { $this->charset = $is_system ? CHARSET : $charset; } /** * Allows to extract recipient's name from text by specifying it's email * * @param string $text * @param string $email * @return string */ function ExtractRecipientName($text, $email = '') { $lastspace = mb_strrpos($text, ' '); $name = trim(mb_substr($text, 0, $lastspace - mb_strlen($text)), " \r\n\t\0\x0b\"'"); if (empty($name)) { $name = $email; } return $name; } /** * Takes $text and returns an email address from it * Set $multiple to true to retrieve all found addresses * Returns false if no addresses were found * * @param string $text * @param bool $multiple * @param bool $allow_only_domain * @return Array|bool * @access public */ public function ExtractRecipientEmail($text, $multiple = false, $allow_only_domain = false) { if ( $allow_only_domain ) { $pattern = '/((' . REGEX_EMAIL_USER . '@)?' . REGEX_EMAIL_DOMAIN . ')/i'; } else { $pattern = '/(' . REGEX_EMAIL_USER . '@' . REGEX_EMAIL_DOMAIN . ')/i'; } if ( $multiple ) { if ( preg_match_all($pattern, $text, $found_emails) >= 1 ) { return $found_emails[1]; } else { return false; } } else { if ( preg_match($pattern, $text, $found_emails) == 1 ) { return $found_emails[1]; } else { return false; } } } /** * Returns array of recipient names and emails * * @param string $list * @param string $separator * @return Array */ function GetRecipients($list, $separator = ';') { // by MIME specs recipients should be separated using "," symbol, // but users can write ";" too (like in OutLook) if (!trim($list)) { return false; } $list = explode(',', str_replace($separator, ',', $list)); $ret = Array (); foreach ($list as $recipient) { $email = $this->ExtractRecipientEmail($recipient); if (!$email) { // invalid email format -> error return false; } $name = $this->ExtractRecipientName($recipient, $email); $ret[] = Array('Name' => $name, 'Email' => $email); } return $ret; } /* methods for nice header setting */ /** * Sets "From" header. * * @param string $email * @param string $first_last_name FirstName and LastName or just FirstName * @param string $last_name LastName (if not specified in previous parameter) */ function SetFrom($email, $first_last_name, $last_name = '') { $name = rtrim($first_last_name.' '.$last_name, ' '); $this->SetEncodedEmailHeader('From', $email, $name ? $name : $email); if (!isset($this->headers['Return-Path'])) { $this->SetReturnPath($email); } } /** * Sets "To" header. * * @param string $email * @param string $first_last_name FirstName and LastName or just FirstName * @param string $last_name LastName (if not specified in previous parameter) */ function SetTo($email, $first_last_name, $last_name = '') { $name = rtrim($first_last_name.' '.$last_name, ' '); $email = $this->_replaceRecipientEmail($email); $this->SetEncodedEmailHeader('To', $email, $name ? $name : $email); } /** * Sets "Return-Path" header (useful for spammers) * * @param string $email */ function SetReturnPath($email) { $this->SetHeader('Return-Path', $email); } /** * Adds one more recipient into "To" header * * @param string $email * @param string $first_last_name FirstName and LastName or just FirstName * @param string $last_name LastName (if not specified in previous parameter) */ function AddTo($email, $first_last_name = '', $last_name = '') { $name = rtrim($first_last_name.' '.$last_name, ' '); $this->AddRecipient('To', $email, $name); } /** * Allows to replace recipient in all sent emails (used for debugging) * * @param string $email * @return string */ function _replaceRecipientEmail($email) { if ( defined('OVERRIDE_EMAIL_RECIPIENTS') && OVERRIDE_EMAIL_RECIPIENTS ) { if ( substr(OVERRIDE_EMAIL_RECIPIENTS, 0, 1) == '@' ) { // domain $email = str_replace('@', '_at_', $email) . OVERRIDE_EMAIL_RECIPIENTS; } else { $email = OVERRIDE_EMAIL_RECIPIENTS; } } return $email; } /** * Adds one more recipient into "Cc" header * * @param string $email * @param string $first_last_name FirstName and LastName or just FirstName * @param string $last_name LastName (if not specified in previous parameter) */ function AddCc($email, $first_last_name = '', $last_name = '') { $name = rtrim($first_last_name.' '.$last_name, ' '); $this->AddRecipient('Cc', $email, $name); } /** * Adds one more recipient into "Bcc" header * * @param string $email * @param string $first_last_name FirstName and LastName or just FirstName * @param string $last_name LastName (if not specified in previous parameter) */ function AddBcc($email, $first_last_name = '', $last_name = '') { $name = rtrim($first_last_name.' '.$last_name, ' '); $this->AddRecipient('Bcc', $email, $name); } /** * Adds one more recipient to specified header * * @param string $header_name * @param string $email * @param string $name */ function AddRecipient($header_name, $email, $name = '') { $email = $this->_replaceRecipientEmail($email); if (!$name) { $name = $email; } $value = isset($this->headers[$header_name]) ? $this->headers[$header_name] : ''; if ( $value ) { // not first recipient added - separate with comma $value .= ', '; } $value .= $this->QuotedPrintableEncode($name, $this->charset) . ' <' . $email . '>'; $this->SetHeader($header_name, $value); } /** + * Returns list of recipients from given header. + * + * @param string $header_name Header name. + * + * @return array + */ + public function GetRecipientsByHeader($header_name) + { + if ( !isset($this->headers[$header_name]) ) { + return array(); + } + + $decoded_header = $this->decodeHeader($this->headers[$header_name]); + $recipients = $this->GetRecipients($decoded_header); + + if ( $recipients === false ) { + return array(); + } + + return $recipients; + } + + /** + * Decodes header value. + * + * @param string $header_value Header value. + * + * @return string + */ + protected function decodeHeader($header_value) + { + while ( preg_match('/(=\?([^?]+)\?(Q|B)\?([^?]*)\?=)/i', $header_value, $matches) ) { + $encoded = $matches[1]; + $charset = $matches[2]; + $encoding = $matches[3]; + $text = $matches[4]; + + switch ( strtoupper($encoding) ) { + case 'B': + $text = base64_decode($text); + break; + + case 'Q': + $text = str_replace('_', ' ', $text); + preg_match_all('/=([a-f0-9]{2})/i', $text, $matches); + + foreach ( $matches[1] as $value ) { + $text = str_replace('=' . $value, chr(hexdec($value)), $text); + } + break; + } + + $header_value = mb_convert_encoding( + str_replace($encoded, $text, $header_value), + $this->charset, + $charset + ); + } + + return $header_value; + } + + /** * Sets "Subject" header. * * @param string $subject message subject */ function SetSubject($subject) { $this->setEncodedHeader('Subject', $subject); } /** * Sets HTML part of message * * @param string $html */ function SetHTML($html) { $this->CreateTextHtmlPart($html, true); } /** * Sets Plain-Text part of message * * @param string $plain_text */ function SetPlain($plain_text) { $this->CreateTextHtmlPart($plain_text); } /** * Sets HTML and optionally plain part of the message * * @param string $html * @param string $plain_text */ function SetBody($html, $plain_text = '') { $this->SetHTML($html); if ($plain_text) { $this->SetPlain($plain_text); } } /** * Performs mail delivery (supports delayed delivery) * * @param string $message message, if not given, then use composed one * @param bool $immediate_send send message now or MailingId * @param bool $immediate_clear clear message parts after message is sent * @return bool */ function Deliver($message = null, $immediate_send = true, $immediate_clear = true) { if (isset($message)) { // if message is given directly, then use it if (is_array($message)) { $message_headers =& $message[0]; $message_body =& $message[1]; } else { $message_headers = Array (); list ($headers, $message_body) = explode("\n\n", $message, 2); $headers = explode("\n", $headers); foreach ($headers as $header) { $header = explode(':', $header, 2); $message_headers[ trim($header[0]) ] = trim($header[1]); } } $composed = true; } else { // direct message not given, then assemble message from available parts $composed = $this->GetHeadersAndBody($message_headers, $message_body); } if ( $composed ) { if ( $immediate_send === true ) { $send_method = 'Send' . $this->sendMethod; $result = $this->$send_method($message_headers, $message_body); if ( $result && $this->_logData ) { // add e-mail log record $this->Conn->doInsert($this->_logData, TABLE_PREFIX . 'EmailLog'); } if ( $immediate_clear ) { $this->Clear(); } return $result; } else { $fields_hash = Array ( 'ToEmail' => $message_headers['To'], 'Subject' => $message_headers['Subject'], 'Queued' => adodb_mktime(), 'SendRetries' => 0, 'LastSendRetry' => 0, 'MailingId' => (int)$immediate_send, 'LogData' => serialize($this->_logData), // remember e-mail log record ); $fields_hash['MessageHeaders'] = serialize($message_headers); $fields_hash['MessageBody'] =& $message_body; $this->Conn->doInsert($fields_hash, TABLE_PREFIX . 'EmailQueue'); if ( $immediate_clear ) { $this->Clear(); } } } // if not immediate send, then send result is positive :) return $immediate_send !== true ? true : false; } /** * Sets log data * * @param string $log_data * @return void * @access public */ public function setLogData($log_data) { $this->_logData = $log_data; } - } \ No newline at end of file + } Index: branches/5.2.x/core/kernel/utility/email.php =================================================================== --- branches/5.2.x/core/kernel/utility/email.php (revision 16198) +++ branches/5.2.x/core/kernel/utility/email.php (revision 16199) @@ -1,862 +1,900 @@ Array (), EmailTemplate::RECIPIENT_TYPE_CC => Array (), EmailTemplate::RECIPIENT_TYPE_BCC => Array (), ); /** * Creates e-mail instance */ public function __construct() { parent::__construct(); $this->sender = $this->Application->recallObject('EmailSender'); } /** * Resets state of e-mail * * @return void * @access protected */ protected function _resetState() { $this->fromEmail = $this->fromName = ''; $this->Application->removeObject('u.email-from'); $this->recipients = Array ( EmailTemplate::RECIPIENT_TYPE_TO => Array (), EmailTemplate::RECIPIENT_TYPE_CC => Array (), EmailTemplate::RECIPIENT_TYPE_BCC => Array (), ); $this->toEmail = $this->toEmail = ''; $this->Application->removeObject('u.email-to'); } /** * Finds e-mail template matching user data * * @param string $name * @param int $type * @return bool * @throws InvalidArgumentException * @access public */ public function findTemplate($name, $type) { if ( !$name || !preg_match('/^[A-Z\.]+$/', $name) ) { throw new InvalidArgumentException('Invalid e-mail template name "' . $name . '". Only UPPERCASE characters and dots are allowed.'); } if ( $type != EmailTemplate::TEMPLATE_TYPE_ADMIN && $type != EmailTemplate::TEMPLATE_TYPE_FRONTEND ) { throw new InvalidArgumentException('Invalid e-mail template type'); } // use "-item" special prevent error, when e-mail sent out from e-mail templates list $this->emailTemplate = $this->Application->recallObject('email-template.-item', null, Array ('skip_autoload' => true)); if ( !$this->emailTemplate->isLoaded() || !$this->_sameTemplate($name, $type) ) { // get template parameters by name & type $this->emailTemplate->Load(Array ('TemplateName' => $name, 'Type' => $type)); } return $this->_templateUsable(); } /** * Detects, that given e-mail template data matches currently used e-mail template * * @param string $name * @param int $type * @return bool * @access protected */ protected function _sameTemplate($name, $type) { return $this->emailTemplate->GetDBField('TemplateName') == $name && $this->emailTemplate->GetDBField('Type') == $type; } /** * Determines if we can use e-mail template we've found based on user data * * @return bool * @access protected */ protected function _templateUsable() { if ( !$this->emailTemplate->isLoaded() || $this->emailTemplate->GetDBField('Enabled') == STATUS_DISABLED ) { return false; } if ( $this->emailTemplate->GetDBField('FrontEndOnly') && $this->Application->isAdmin ) { return false; } return true; } /** * Sets e-mail template params * * @param Array $params * @access public */ public function setParams($params) { $this->params = $params; } /** * Returns any custom parameters, that are passed when invoked e-mail template sending * * @return Array * @access protected */ protected function _getCustomParams() { $ret = $this->params; $send_keys = Array ('from_email', 'from_name', 'to_email', 'to_name', 'overwrite_to_email', 'language_id', 'use_custom_design'); foreach ($send_keys as $send_key) { unset($ret[$send_key]); } return $ret; } /** * Sends e-mail now or puts it in queue * * @param int $recipient_user_id * @param bool $immediate_send * @return bool * @access public */ public function send($recipient_user_id = null, $immediate_send = true) { $this->recipientUserId = $recipient_user_id; $this->_resetState(); $this->_processSender(); $this->_processRecipients(); $this->_changeLanguage(false); // 1. set headers $message_headers = $this->_getHeaders(); $message_subject = isset($message_headers['Subject']) ? $message_headers['Subject'] : 'Mail message'; $this->sender->SetSubject($message_subject); foreach ($message_headers as $header_name => $header_value) { $this->sender->SetEncodedHeader($header_name, $header_value); } if ( $this->_storeEmailLog() ) { // 2. prepare log $log_fields_hash = Array ( 'From' => $this->fromName . ' (' . $this->fromEmail . ')', 'To' => $this->toName . ' (' . $this->toEmail . ')', 'OtherRecipients' => serialize($this->recipients), 'Subject' => $message_subject, 'SentOn' => TIMENOW, 'TemplateName' => $this->emailTemplate->GetDBField('TemplateName'), 'EventType' => $this->emailTemplate->GetDBField('Type'), 'EventParams' => serialize($this->_getCustomParams()), ); $this->params['email_access_key'] = $this->_generateAccessKey($log_fields_hash); } // 3. set body $html_message_body = $this->_getMessageBody(true); $plain_message_body = $this->_getMessageBody(false); if ( $html_message_body === false && $plain_message_body === false ) { trigger_error('Message template is empty (maybe after parsing).', E_USER_WARNING); return false; } if ( $html_message_body !== false ) { $this->sender->CreateTextHtmlPart($html_message_body, true); } if ( $plain_message_body !== false ) { $this->sender->CreateTextHtmlPart($plain_message_body, false); } $this->_changeLanguage(true); if ( $this->_storeEmailLog() ) { // 4. set log $log_fields_hash['HtmlBody'] = $html_message_body; $log_fields_hash['TextBody'] = $plain_message_body; $log_fields_hash['AccessKey'] = $this->params['email_access_key']; $this->sender->setLogData($log_fields_hash); } return $this->sender->Deliver(null, $immediate_send); } /** * Determines whatever we should keep e-mail log or not * * @return bool * @access protected */ protected function _storeEmailLog() { return $this->Application->ConfigValue('EnableEmailLog'); } /** * Generates access key for accessing e-mail later * * @param Array $log_fields_hash * @return string * @access protected */ protected function _generateAccessKey($log_fields_hash) { $ret = ''; $use_fields = Array ('From', 'To', 'Subject'); foreach ($use_fields as $use_field) { $ret .= $log_fields_hash[$use_field] . ':'; } return md5($ret . microtime(true)); } /** * Processes email sender * * @return void * @access protected */ protected function _processSender() { if ( $this->emailTemplate->GetDBField('CustomSender') ) { $this->_processCustomSender(); } // update with custom data given during event execution if ( isset($this->params['from_email']) ) { $this->fromEmail = $this->params['from_email']; } if ( isset($this->params['from_name']) ) { $this->fromName = $this->params['from_name']; } // still nothing, set defaults $this->_ensureDefaultSender(); $this->sender->SetFrom($this->fromEmail, $this->fromName); } /** * Processes custom e-mail sender * * @return void * @access protected */ protected function _processCustomSender() { $address = $this->emailTemplate->GetDBField('SenderAddress'); $address_type = $this->emailTemplate->GetDBField('SenderAddressType'); switch ($address_type) { case EmailTemplate::ADDRESS_TYPE_EMAIL: $this->fromEmail = $address; break; case EmailTemplate::ADDRESS_TYPE_USER: $sql = 'SELECT FirstName, LastName, Email, PortalUserId FROM ' . TABLE_PREFIX . 'Users WHERE Username = ' . $this->Conn->qstr($address); $user_info = $this->Conn->GetRow($sql); if ( $user_info ) { // user still exists $this->fromEmail = $user_info['Email']; $this->fromName = trim($user_info['FirstName'] . ' ' . $user_info['LastName']); $user = $this->Application->recallObject('u.email-from', null, Array ('skip_autoload' => true)); /* @var $user UsersItem */ $user->Load($user_info['PortalUserId']); } break; } if ( $this->emailTemplate->GetDBField('SenderName') ) { $this->fromName = $this->emailTemplate->GetDBField('SenderName'); } } /** * Ensures, that sender name & e-mail are not empty * * @return void * @access protected */ protected function _ensureDefaultSender() { if ( !$this->fromEmail ) { $this->fromEmail = $this->Application->ConfigValue('DefaultEmailSender'); } if ( !$this->fromName ) { $this->fromName = strip_tags($this->Application->ConfigValue('Site_Name')); } } /** * Processes email recipients * * @return void * @access protected */ protected function _processRecipients() { $this->_collectRecipients(); - $header_mapping = Array ( - EmailTemplate::RECIPIENT_TYPE_TO => 'To', - EmailTemplate::RECIPIENT_TYPE_CC => 'Cc', - EmailTemplate::RECIPIENT_TYPE_BCC => 'Bcc', - ); + $header_mapping = $this->getHeaderMapping(); $default_email = $this->Application->ConfigValue('DefaultEmailSender'); $this->recipients = array_map(Array ($this, '_transformRecipientsIntoPairs'), $this->recipients); foreach ($this->recipients as $recipient_type => $recipients) { // add recipients to email if ( !$recipients ) { continue; } if ( $recipient_type == EmailTemplate::RECIPIENT_TYPE_TO ) { $this->toEmail = $recipients[0]['email'] ? $recipients[0]['email'] : $default_email; $this->toName = $recipients[0]['name'] ? $recipients[0]['name'] : $this->toEmail; } $header_name = $header_mapping[$recipient_type]; foreach ($recipients as $recipient) { $email = $recipient['email'] ? $recipient['email'] : $default_email; $name = $recipient['name'] ? $recipient['name'] : $email; $this->sender->AddRecipient($header_name, $email, $name); } } } /** * Collects e-mail recipients from various sources * * @return void * @access protected */ protected function _collectRecipients() { $this->_addRecipientsFromXml($this->emailTemplate->GetDBField('Recipients')); $this->_overwriteToRecipient(); $this->_addRecipientByUserId(); $this->_addRecipientFromParams(); + $this->_moveDirectRecipients(); if ( ($this->emailTemplate->GetDBField('Type') == EmailTemplate::TEMPLATE_TYPE_ADMIN) && !$this->recipients[EmailTemplate::RECIPIENT_TYPE_TO] ) { // admin email template without direct recipient -> send to admin $this->_addDefaultRecipient(); } } /** * Adds multiple recipients from an XML * * @param string $xml * @return bool * @access protected */ protected function _addRecipientsFromXml($xml) { if ( !$xml ) { return false; } $minput_helper = $this->Application->recallObject('MInputHelper'); /* @var $minput_helper MInputHelper */ // group recipients by type $records = $minput_helper->parseMInputXML($xml); foreach ($records as $record) { $this->recipients[$record['RecipientType']][] = $record; } return true; } /** * Remove all "To" recipients, when not allowed * * @return void * @access protected */ protected function _overwriteToRecipient() { $overwrite_to_email = isset($this->params['overwrite_to_email']) ? $this->params['overwrite_to_email'] : false; if ( !$this->emailTemplate->GetDBField('CustomRecipient') || $overwrite_to_email ) { $this->recipients[EmailTemplate::RECIPIENT_TYPE_TO] = Array (); } } /** * Update with custom data given during event execution (user_id) * * @return void * @access protected */ protected function _addRecipientByUserId() { if ( !is_numeric($this->recipientUserId) ) { return; } if ( $this->recipientUserId <= 0 ) { // recipient is system user with negative ID (root, guest, etc.) -> send to admin $this->_addDefaultRecipient(); return; } $language_field = $this->emailTemplate->GetDBField('Type') == EmailTemplate::TEMPLATE_TYPE_FRONTEND ? 'FrontLanguage' : 'AdminLanguage'; $sql = 'SELECT FirstName, LastName, Email, ' . $language_field . ' AS Language FROM ' . TABLE_PREFIX . 'Users WHERE PortalUserId = ' . $this->recipientUserId; $user_info = $this->Conn->GetRow($sql); if ( !$user_info ) { return; } $add_recipient = Array ( 'RecipientAddressType' => EmailTemplate::ADDRESS_TYPE_EMAIL, 'RecipientAddress' => $user_info['Email'], 'RecipientName' => trim($user_info['FirstName'] . ' ' . $user_info['LastName']), ); if ( $user_info['Language'] && !isset($this->params['language_id']) ) { $this->params['language_id'] = $user_info['Language']; } array_unshift($this->recipients[EmailTemplate::RECIPIENT_TYPE_TO], $add_recipient); $user = $this->Application->recallObject('u.email-to', null, Array('skip_autoload' => true)); /* @var $user UsersItem */ $user->Load($this->recipientUserId); } /** * Update with custom data given during event execution (email + name) * * @return void * @access protected */ protected function _addRecipientFromParams() { $add_recipient = Array (); if ( isset($this->params['to_email']) && $this->params['to_email'] ) { $add_recipient['RecipientName'] = ''; $add_recipient['RecipientAddressType'] = EmailTemplate::ADDRESS_TYPE_EMAIL; $add_recipient['RecipientAddress'] = $this->params['to_email']; } if ( isset($this->params['to_name']) && $this->params['to_name'] ) { $add_recipient['RecipientName'] = $this->params['to_name']; } if ( $add_recipient ) { array_unshift($this->recipients[EmailTemplate::RECIPIENT_TYPE_TO], $add_recipient); } } /** + * Move recipients, that were added manually via "$this->sender->Add*" methods. + * + * @return void + * @access protected + */ + protected function _moveDirectRecipients() + { + foreach ( $this->getHeaderMapping() as $recipient_type => $header_name ) { + $manual_recipients = $this->sender->GetRecipientsByHeader($header_name); + + if ( !$manual_recipients ) { + continue; + } + + foreach ( $manual_recipients as $manual_recipient ) { + $this->recipients[$recipient_type][] = array( + 'RecipientName' => $manual_recipient['Name'], + 'RecipientAddressType' => EmailTemplate::ADDRESS_TYPE_EMAIL, + 'RecipientAddress' => $manual_recipient['Email'], + ); + } + + $this->sender->SetHeader($header_name, ''); + } + } + + /** + * Returns mapping between recipient type and header name. + * + * @return array + */ + protected function getHeaderMapping() + { + return array( + EmailTemplate::RECIPIENT_TYPE_TO => 'To', + EmailTemplate::RECIPIENT_TYPE_CC => 'Cc', + EmailTemplate::RECIPIENT_TYPE_BCC => 'Bcc', + ); + } + + /** * This is default recipient, when we can't determine actual one * * @return void * @access protected */ protected function _addDefaultRecipient() { $xml = $this->Application->ConfigValue('DefaultEmailRecipients'); if ( !$this->_addRecipientsFromXml($xml) ) { $recipient = Array ( 'RecipientName' => $this->Application->ConfigValue('DefaultEmailSender'), 'RecipientAddressType' => EmailTemplate::ADDRESS_TYPE_EMAIL, 'RecipientAddress' => $this->Application->ConfigValue('DefaultEmailSender'), ); array_unshift($this->recipients[EmailTemplate::RECIPIENT_TYPE_TO], $recipient); } } /** * Transforms recipients into name/e-mail pairs * * @param Array $recipients * @return Array * @access protected */ protected function _transformRecipientsIntoPairs($recipients) { if ( !$recipients ) { return Array (); } $pairs = Array (); foreach ($recipients as $recipient) { $address = $recipient['RecipientAddress']; $address_type = $recipient['RecipientAddressType']; $recipient_name = $recipient['RecipientName']; switch ($address_type) { case EmailTemplate::ADDRESS_TYPE_EMAIL: $pairs[] = Array ('email' => $address, 'name' => $recipient_name); break; case EmailTemplate::ADDRESS_TYPE_USER: $sql = 'SELECT FirstName, LastName, Email FROM ' . TABLE_PREFIX . 'Users WHERE Username = ' . $this->Conn->qstr($address); $user_info = $this->Conn->GetRow($sql); if ( $user_info ) { // user still exists $name = trim($user_info['FirstName'] . ' ' . $user_info['LastName']); $pairs[] = Array ( 'email' => $user_info['Email'], 'name' => $name ? $name : $recipient_name, ); } break; case EmailTemplate::ADDRESS_TYPE_GROUP: $sql = 'SELECT u.FirstName, u.LastName, u.Email FROM ' . TABLE_PREFIX . 'UserGroups g JOIN ' . TABLE_PREFIX . 'UserGroupRelations ug ON ug.GroupId = g.GroupId JOIN ' . TABLE_PREFIX . 'Users u ON u.PortalUserId = ug.PortalUserId WHERE g.Name = ' . $this->Conn->qstr($address); $users = $this->Conn->Query($sql); foreach ($users as $user_info) { $name = trim($user_info['FirstName'] . ' ' . $user_info['LastName']); $pairs[] = Array ( 'email' => $user_info['Email'], 'name' => $name ? $name : $recipient_name, ); } break; } } return $pairs; } /** * Change system language temporarily to send e-mail on user language * * @param bool $restore * @return void * @access protected */ protected function _changeLanguage($restore = false) { static $prev_language_id = null; if ( !isset($prev_language_id) ) { $prev_language_id = $this->Application->GetVar('m_lang'); } // ensure that language is set if ( !isset($this->params['language_id']) ) { $this->params['language_id'] = $this->Application->GetVar('m_lang'); } $language_id = $restore ? $prev_language_id : $this->params['language_id']; $this->Application->SetVar('m_lang', $language_id); $language = $this->Application->recallObject('lang.current'); /* @var $language LanguagesItem */ $language->Load($language_id); $this->Application->Phrases->LanguageId = $language_id; $this->Application->Phrases->Phrases = Array (); } /** * Parses message headers into array * * @return Array * @access protected */ protected function _getHeaders() { $headers = $this->emailTemplate->GetDBField('Headers'); $headers = 'Subject: ' . $this->emailTemplate->GetField('Subject') . ($headers ? "\n" . $headers : ''); $headers = explode("\n", $this->_parseText($headers)); $ret = Array (); foreach ($headers as $header) { $header = explode(':', $header, 2); $ret[ trim($header[0]) ] = trim($header[1]); } if ( $this->Application->isDebugMode() ) { // set special header with template name, so it will be easier to determine what's actually was received $template_type = $this->emailTemplate->GetDBField('Type') == EmailTemplate::TEMPLATE_TYPE_ADMIN ? 'ADMIN' : 'USER'; $ret['X-Template-Name'] = $this->emailTemplate->GetDBField('TemplateName') . ' - ' . $template_type; } return $ret; } /** * Applies design to given e-mail text * * @param string $text * @param bool $is_html * @return string * @access protected */ protected function _applyMessageDesign($text, $is_html = true) { static $design_templates = Array(); $design_key = 'L' . $this->params['language_id'] . ':' . ($is_html ? 'html' : 'text'); if ( !isset($design_templates[$design_key]) ) { $language = $this->Application->recallObject('lang.current'); /* @var $language LanguagesItem */ $design_template = $language->GetDBField($is_html ? 'HtmlEmailTemplate' : 'TextEmailTemplate'); if ( !$is_html && !$design_template ) { $design_template = $this->sender->ConvertToText($language->GetDBField('HtmlEmailTemplate'), true); } $design_templates[$design_key] = $design_template; } return $this->_parseText(str_replace('$body', $text, $design_templates[$design_key]), $is_html); } /** * Returns message body * * @param bool $is_html * @return bool|string * @access protected */ protected function _getMessageBody($is_html = false) { $message_body = $this->emailTemplate->GetField($is_html ? 'HtmlBody' : 'PlainTextBody'); if ( !trim($message_body) && !$is_html ) { // no plain text part available -> make it from html part then $message_body = $this->sender->ConvertToText($this->emailTemplate->GetField('HtmlBody'), true); } if ( !trim($message_body) ) { return false; } if ( isset($this->params['use_custom_design']) && $this->params['use_custom_design'] ) { $message_body = $this->_parseText($message_body, $is_html); } else { $message_body = $this->_applyMessageDesign($message_body, $is_html); } return trim($message_body) ? $message_body : false; } /** * Parse message template and return headers (as array) and message body part * * @param string $text * @param bool $is_html * @return string * @access protected */ protected function _parseText($text, $is_html = true) { $text = $this->_substituteReplacementTags($text); if ( !$text ) { return ''; } // init for cases, when e-mail is sent from event before page template rendering $this->Application->InitParser(); $parser_params = $this->Application->Parser->Params; // backup parser params $this->Application->Parser->SetParams($this->params); $template_name = 'et_' . $this->emailTemplate->GetID() . '_' . crc32($text); $text = $this->Application->Parser->Parse($this->_normalizeLineEndings($text), $template_name); $this->Application->Parser->SetParams($parser_params); // restore parser params $category_helper = $this->Application->recallObject('CategoryHelper'); /* @var $category_helper CategoryHelper */ return $category_helper->replacePageIds($is_html ? $this->_removeTrailingLineEndings($text) : $text); } /** * Substitutes replacement tags in given text * * @param string $text * @return string * @access protected */ protected function _substituteReplacementTags($text) { $default_replacement_tags = Array ( ' ' ' 'emailTemplate->GetDBField('ReplacementTags'); $replacement_tags = $replacement_tags ? unserialize($replacement_tags) : Array (); $replacement_tags = array_merge($default_replacement_tags, $replacement_tags); foreach ($replacement_tags as $replace_from => $replace_to) { $text = str_replace($replace_from, $replace_to, $text); } return $text; } /** * Convert Unix/Windows/Mac line ending into Unix line endings * * @param string $text * @return string * @access protected */ protected function _normalizeLineEndings($text) { return str_replace(Array ("\r\n", "\r"), "\n", $text); } /** * Remove trailing line endings * * @param $text * @return string * @access protected */ protected function _removeTrailingLineEndings($text) { return preg_replace('/(\n|\r)+/', "\\1", $text); } }