1<?php 2/********************************************************************* 3 class.mailer.php 4 5 osTicket mailer 6 7 It's mainly PEAR MAIL wrapper for now (more improvements planned). 8 9 Peter Rotich <peter@osticket.com> 10 Copyright (c) 2006-2013 osTicket 11 http://www.osticket.com 12 13 Released under the GNU General Public License WITHOUT ANY WARRANTY. 14 See LICENSE.TXT for details. 15 16 vim: expandtab sw=4 ts=4 sts=4: 17**********************************************************************/ 18 19include_once(INCLUDE_DIR.'class.email.php'); 20require_once(INCLUDE_DIR.'html2text.php'); 21 22class Mailer { 23 24 var $email; 25 26 var $ht = array(); 27 var $attachments = array(); 28 var $options = array(); 29 30 var $smtp = array(); 31 var $eol="\n"; 32 33 function __construct($email=null, array $options=array()) { 34 global $cfg; 35 36 if(is_object($email) && $email->isSMTPEnabled() && ($info=$email->getSMTPInfo())) { //is SMTP enabled for the current email? 37 $this->smtp = $info; 38 } elseif($cfg && ($e=$cfg->getDefaultSMTPEmail()) && $e->isSMTPEnabled()) { //What about global SMTP setting? 39 $this->smtp = $e->getSMTPInfo(); 40 if(!$e->allowSpoofing() || !$email) 41 $email = $e; 42 } elseif(!$email && $cfg && ($e=$cfg->getDefaultEmail())) { 43 if($e->isSMTPEnabled() && ($info=$e->getSMTPInfo())) 44 $this->smtp = $info; 45 $email = $e; 46 } 47 48 $this->email = $email; 49 $this->attachments = array(); 50 $this->options = $options; 51 } 52 53 function getEOL() { 54 return $this->eol; 55 } 56 57 function getEmail() { 58 return $this->email; 59 } 60 61 function getSMTPInfo() { 62 return $this->smtp; 63 } 64 /* FROM Address */ 65 function setFromAddress($from) { 66 $this->ht['from'] = $from; 67 } 68 69 function getFromAddress($options=array()) { 70 71 if (!$this->ht['from'] && ($email=$this->getEmail())) { 72 if (($name = $options['from_name'] ?: $email->getName())) 73 $this->ht['from'] =sprintf('"%s" <%s>', $name, $email->getEmail()); 74 else 75 $this->ht['from'] =sprintf('<%s>', $email->getEmail()); 76 } 77 78 return $this->ht['from']; 79 } 80 81 /* attachments */ 82 function getAttachments() { 83 return $this->attachments; 84 } 85 86 function addAttachment(Attachment $attachment) { 87 // XXX: This looks too assuming; however, the attachment processor 88 // in the ::send() method seems hard coded to expect this format 89 $this->attachments[] = $attachment; 90 } 91 92 function addAttachmentFile(AttachmentFile $file) { 93 // XXX: This looks too assuming; however, the attachment processor 94 // in the ::send() method seems hard coded to expect this format 95 $this->attachments[] = $file; 96 } 97 98 function addFileObject(FileObject $file) { 99 $this->attachments[] = $file; 100 } 101 102 function addAttachments($attachments) { 103 foreach ($attachments as $a) { 104 if ($a instanceof Attachment) 105 $this->addAttachment($a); 106 elseif ($a instanceof AttachmentFile) 107 $this->addAttachmentFile($a); 108 elseif ($a instanceof FileObject) 109 $this->addFileObject($a); 110 } 111 } 112 113 /** 114 * getMessageId 115 * 116 * Generates a unique message ID for an outbound message. Optionally, 117 * the recipient can be used to create a tag for the message ID where 118 * the user-id and thread-entry-id are encoded in the message-id so 119 * the message can be threaded if it is replied to without any other 120 * indicator of the thread to which it belongs. This tag is signed with 121 * the secret-salt of the installation to guard against false positives. 122 * 123 * Parameters: 124 * $recipient - (EmailContact|null) recipient of the message. The ID of 125 * the recipient is placed in the message id TAG section so it can 126 * be recovered if the email replied to directly by the end user. 127 * $options - (array) - options passed to ::send(). If it includes a 128 * 'thread' element, the threadId will be recorded in the TAG 129 * 130 * Returns: 131 * (string) - email message id, without leading and trailing <> chars. 132 * See the Format below for the structure. 133 * 134 * Format: 135 * VA-B-C, with dash separators and A-C explained below: 136 * 137 * V: Version code of the generated Message-Id 138 * A: Predictable random code — used for loop detection (sysid) 139 * B: Random data for unique identifier (rand) 140 * C: TAG: Base64(Pack(userid, entryId, threadId, type, Signature)), 141 * '=' chars discarded 142 * where Signature is: 143 * Signed Tag value, last 5 chars from 144 * HMAC(sha1, Tag + rand + sysid, SECRET_SALT), 145 * where Tag is: 146 * pack(userId, entryId, threadId, type) 147 */ 148 function getMessageId($recipient, $options=array(), $version='B') { 149 $tag = ''; 150 $rand = Misc::randCode(5, 151 // RFC822 specifies the LHS of the addr-spec can have any char 152 // except the specials — ()<>@,;:\".[], dash is reserved as the 153 // section separator, and + is reserved for historical reasons 154 'abcdefghiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_='); 155 $sig = $this->getEmail()?$this->getEmail()->getEmail():'@osTicketMailer'; 156 $sysid = static::getSystemMessageIdCode(); 157 // Create a tag for the outbound email 158 $entry = (isset($options['thread']) && $options['thread'] instanceof ThreadEntry) 159 ? $options['thread'] : false; 160 $thread = $entry ? $entry->getThread() 161 : (isset($options['thread']) && $options['thread'] instanceof Thread 162 ? $options['thread'] : false); 163 164 switch (true) { 165 case $recipient instanceof Staff: 166 $utype = 'S'; 167 break; 168 case $recipient instanceof TicketOwner: 169 $utype = 'U'; 170 break; 171 case $recipient instanceof Collaborator: 172 $utype = 'C'; 173 break; 174 case $recipient instanceof MailingList: 175 $utype = 'M'; 176 break; 177 default: 178 $utype = $options['utype'] ?: is_array($recipient) ? 'M' : '?'; 179 } 180 181 182 $tag = pack('VVVa', 183 $recipient instanceof EmailContact ? $recipient->getUserId() : 0, 184 $entry ? $entry->getId() : 0, 185 $thread ? $thread->getId() : 0, 186 $utype ?: '?' 187 ); 188 // Sign the tag with the system secret salt 189 $tag .= substr(hash_hmac('sha1', $tag.$rand.$sysid, SECRET_SALT, true), -5); 190 $tag = str_replace('=','',base64_encode($tag)); 191 return sprintf('B%s-%s-%s-%s', 192 $sysid, $rand, $tag, $sig); 193 } 194 195 /** 196 * decodeMessageId 197 * 198 * Decodes a message-id generated by osTicket using the ::getMessageId() 199 * method of this class. This will digest the received message-id token 200 * and return an array with some information about it. 201 * 202 * Parameters: 203 * $mid - (string) message-id from an email Message-Id, In-Reply-To, and 204 * References header. 205 * 206 * Returns: 207 * (array) of information containing all or some of the following keys 208 * 'loopback' - (bool) true or false if the message originated by 209 * this osTicket installation. 210 * 'version' - (string|FALSE) version code of the message id 211 * 'code' - (string) unique but predictable help desk message-id 212 * 'id' - (string) random characters serving as the unique id 213 * 'entryId' - (int) thread-entry-id from which the message originated 214 * 'threadId' - (int) thread-id from which the message originated 215 * 'staffId' - (int|null) staff the email was originally sent to 216 * 'userId' - (int|null) user the email was originally sent to 217 * 'userClass' - (string) class of user the email was sent to 218 * 'U' - TicketOwner 219 * 'S' - Staff 220 * 'C' - Collborator 221 * 'M' - Multiple 222 * '?' - Something else 223 */ 224 static function decodeMessageId($mid) { 225 // Drop <> tokens 226 $mid = trim($mid, '<> '); 227 // Drop email domain on rhs 228 list($lhs, $sig) = explode('@', $mid, 2); 229 // LHS should be tokenized by '-' 230 $parts = explode('-', $lhs); 231 232 $rv = array('loopback' => false, 'version' => false); 233 234 // There should be at least two tokens if the message was sent by 235 // this system. Otherwise, there's nothing to be detected 236 if (count($parts) < 2) 237 return $rv; 238 239 $self = get_called_class(); 240 $decoders = array( 241 'A' => function($id, $tag) use ($sig) { 242 // Old format was VA-B-C-D@sig, where C was the packed tag and D 243 // was blank 244 $format = 'Vuid/VentryId/auserClass'; 245 $chksig = substr(hash_hmac('sha1', $tag.$id, SECRET_SALT), -10); 246 if ($tag && $sig == $chksig && ($tag = base64_decode($tag))) { 247 // Find user and ticket id 248 return unpack($format, $tag); 249 } 250 return false; 251 }, 252 'B' => function($id, $tag) use ($self) { 253 $format = 'Vuid/VentryId/VthreadId/auserClass/a*sig'; 254 if ($tag && ($tag = base64_decode($tag))) { 255 if (!($info = @unpack($format, $tag)) || !isset($info['sig'])) 256 return false; 257 $sysid = $self::getSystemMessageIdCode(); 258 $shorttag = substr($tag, 0, 13); 259 $chksig = substr(hash_hmac('sha1', $shorttag.$id.$sysid, 260 SECRET_SALT, true), -5); 261 if ($chksig == $info['sig']) { 262 return $info; 263 } 264 } 265 return false; 266 }, 267 ); 268 269 // Detect the MessageId version, which should be the first char 270 $rv['version'] = @$parts[0][0]; 271 if (!isset($decoders[$rv['version']])) 272 // invalid version code 273 return null; 274 275 // Drop the leading version code 276 list($rv['code'], $rv['id'], $tag) = $parts; 277 $rv['code'] = substr($rv['code'], 1); 278 279 // Verify tag signature and unpack the tag 280 $info = $decoders[$rv['version']]($rv['id'], $tag); 281 if ($info === false) 282 return $rv; 283 284 $rv += $info; 285 286 // Attempt to make the user-id more specific 287 $classes = array( 288 'S' => 'staffId', 'U' => 'userId', 'C' => 'userId', 289 ); 290 if (isset($classes[$rv['userClass']])) 291 $rv[$classes[$rv['userClass']]] = $rv['uid']; 292 293 // Round-trip detection - the first section is the local 294 // system's message-id code 295 $rv['loopback'] = (0 === strcmp($rv['code'], 296 static::getSystemMessageIdCode())); 297 298 return $rv; 299 } 300 301 static function getSystemMessageIdCode() { 302 return substr(str_replace('+', '=', 303 base64_encode(md5('mail'.SECRET_SALT, true))), 304 0, 6); 305 } 306 307 function send($recipients, $subject, $message, $options=null) { 308 global $ost, $cfg; 309 310 //Get the goodies 311 require_once (PEAR_DIR.'Mail.php'); // PEAR Mail package 312 require_once (PEAR_DIR.'Mail/mime.php'); // PEAR Mail_Mime packge 313 314 $messageId = $this->getMessageId($recipients, $options); 315 $subject = preg_replace("/(\r\n|\r|\n)/s",'', trim($subject)); 316 $headers = array ( 317 'From' => $this->getFromAddress($options), 318 'Subject' => $subject, 319 'Date'=> date('D, d M Y H:i:s O'), 320 'Message-ID' => "<{$messageId}>", 321 'X-Mailer' =>'osTicket Mailer', 322 ); 323 324 // Add in the options passed to the constructor 325 $options = ($options ?: array()) + $this->options; 326 327 // Message Id Token 328 $mid_token = ''; 329 // Check if the email is threadable 330 if (isset($options['thread']) 331 && $options['thread'] instanceof ThreadEntry 332 && ($thread = $options['thread']->getThread())) { 333 334 // Add email in-reply-to references if not set 335 if (!isset($options['inreplyto'])) { 336 337 $entry = null; 338 switch (true) { 339 case $recipients instanceof MailingList: 340 $entry = $thread->getLastEmailMessage(); 341 break; 342 case $recipients instanceof TicketOwner: 343 case $recipients instanceof Collaborator: 344 $entry = $thread->getLastEmailMessage(array( 345 'user_id' => $recipients->getUserId())); 346 break; 347 case $recipients instanceof Staff: 348 //XXX: is it necessary ?? 349 break; 350 } 351 352 if ($entry && ($mid=$entry->getEmailMessageId())) { 353 $options['inreplyto'] = $mid; 354 $options['references'] = $entry->getEmailReferences(); 355 } 356 } 357 358 // Embedded message id token 359 $mid_token = $messageId; 360 // Set Reply-Tag 361 if (!isset($options['reply-tag'])) { 362 if ($cfg && $cfg->stripQuotedReply()) 363 $options['reply-tag'] = $cfg->getReplySeparator() . '<br/><br/>'; 364 else 365 $options['reply-tag'] = ''; 366 } elseif ($options['reply-tag'] === false) { 367 $options['reply-tag'] = ''; 368 } 369 } 370 371 // Return-Path 372 if (isset($options['nobounce']) && $options['nobounce']) 373 $headers['Return-Path'] = '<>'; 374 elseif ($this->getEmail() instanceof Email) 375 $headers['Return-Path'] = $this->getEmail()->getEmail(); 376 377 // Bulk. 378 if (isset($options['bulk']) && $options['bulk']) 379 $headers+= array('Precedence' => 'bulk'); 380 381 // Auto-reply - mark as autoreply and supress all auto-replies 382 if (isset($options['autoreply']) && $options['autoreply']) { 383 $headers+= array( 384 'Precedence' => 'auto_reply', 385 'X-Autoreply' => 'yes', 386 'X-Auto-Response-Suppress' => 'DR, RN, OOF, AutoReply', 387 'Auto-Submitted' => 'auto-replied'); 388 } 389 390 // Notice (sort of automated - but we don't want auto-replies back 391 if (isset($options['notice']) && $options['notice']) 392 $headers+= array( 393 'X-Auto-Response-Suppress' => 'OOF, AutoReply', 394 'Auto-Submitted' => 'auto-generated'); 395 // In-Reply-To 396 if (isset($options['inreplyto']) && $options['inreplyto']) 397 $headers += array('In-Reply-To' => $options['inreplyto']); 398 399 // References 400 if (isset($options['references']) && $options['references']) { 401 if (is_array($options['references'])) 402 $headers += array('References' => 403 implode(' ', $options['references'])); 404 else 405 $headers += array('References' => $options['references']); 406 } 407 408 // Use general failsafe default initially 409 $eol = "\n"; 410 // MAIL_EOL setting can be defined in `ost-config.php` 411 if (defined('MAIL_EOL') && is_string(MAIL_EOL)) 412 $eol = MAIL_EOL; 413 $mime = new Mail_mime($eol); 414 // Add recipients 415 if (!is_array($recipients) && (!$recipients instanceof MailingList)) 416 $recipients = array($recipients); 417 foreach ($recipients as $recipient) { 418 if ($recipient instanceof ClientSession) 419 $recipient = $recipient->getSessionUser(); 420 switch (true) { 421 case $recipient instanceof EmailRecipient: 422 $addr = sprintf('"%s" <%s>', 423 $recipient->getName(), 424 $recipient->getEmail()); 425 switch ($recipient->getType()) { 426 case 'to': 427 $mime->addTo($addr); 428 break; 429 case 'cc': 430 $mime->addCc($addr); 431 break; 432 case 'bcc': 433 $mime->addBcc($addr); 434 break; 435 } 436 break; 437 case $recipient instanceof TicketOwner: 438 case $recipient instanceof Staff: 439 $mime->addTo(sprintf('"%s" <%s>', 440 $recipient->getName(), 441 $recipient->getEmail())); 442 break; 443 case $recipient instanceof Collaborator: 444 $mime->addCc(sprintf('"%s" <%s>', 445 $recipient->getName(), 446 $recipient->getEmail())); 447 break; 448 case $recipient instanceof EmailAddress: 449 $mime->addTo($recipient->getAddress()); 450 break; 451 default: 452 // Assuming email address. 453 $mime->addTo($recipient); 454 } 455 } 456 457 // Add in extra attachments, if any from template variables 458 if ($message instanceof TextWithExtras 459 && ($attachments = $message->getAttachments()) 460 ) { 461 foreach ($attachments as $a) { 462 $file = $a->getFile(); 463 $mime->addAttachment($file->getData(), 464 $file->getMimeType(), $file->getName(), false); 465 } 466 } 467 468 // If the message is not explicitly declared to be a text message, 469 // then assume that it needs html processing to create a valid text 470 // body 471 $isHtml = true; 472 if (!(isset($options['text']) && $options['text'])) { 473 // Embed the data-mid in such a way that it should be included 474 // in a response 475 if ($options['reply-tag'] || $mid_token) { 476 $message = sprintf('<div style="display:none" 477 class="mid-%s">%s</div>%s', 478 $mid_token, 479 $options['reply-tag'], 480 $message); 481 } 482 483 $txtbody = rtrim(Format::html2text($message, 90, false)) 484 . ($messageId ? "\nRef-Mid: $messageId\n" : ''); 485 $mime->setTXTBody($txtbody); 486 } 487 else { 488 $mime->setTXTBody($message); 489 $isHtml = false; 490 } 491 492 if ($isHtml && $cfg && $cfg->isRichTextEnabled()) { 493 // Pick a domain compatible with pear Mail_Mime 494 $matches = array(); 495 if (preg_match('#(@[0-9a-zA-Z\-\.]+)#', $this->getFromAddress(), $matches)) { 496 $domain = $matches[1]; 497 } else { 498 $domain = '@localhost'; 499 } 500 // Format content-ids with the domain, and add the inline images 501 // to the email attachment list 502 $self = $this; 503 $message = preg_replace_callback('/cid:([\w.-]{32})/', 504 function($match) use ($domain, $mime, $self) { 505 $file = false; 506 foreach ($self->attachments as $id=>$F) { 507 if ($F instanceof Attachment) 508 $F = $F->getFile(); 509 if (strcasecmp($F->getKey(), $match[1]) === 0) { 510 $file = $F; 511 break; 512 } 513 } 514 if (!$file) 515 // Not attached yet attempt to attach it inline 516 $file = AttachmentFile::lookup($match[1]); 517 if (!$file) 518 return $match[0]; 519 $mime->addHTMLImage($file->getData(), 520 $file->getMimeType(), $file->getName(), false, 521 $match[1].$domain); 522 // Don't re-attach the image below 523 unset($self->attachments[$file->getId()]); 524 return $match[0].$domain; 525 }, $message); 526 // Add an HTML body 527 $mime->setHTMLBody($message); 528 } 529 //XXX: Attachments 530 if(($attachments=$this->getAttachments())) { 531 foreach($attachments as $file) { 532 // Read the filename from the Attachment if possible 533 if ($file instanceof Attachment) { 534 $filename = $file->getFilename(); 535 $file = $file->getFile(); 536 } elseif ($file instanceof AttachmentFile) { 537 $filename = $file->getName(); 538 } elseif ($file instanceof FileObject) { 539 $filename = $file->getFilename(); 540 } else 541 continue; 542 543 $mime->addAttachment($file->getData(), 544 $file->getMimeType(), $filename, false); 545 } 546 } 547 548 //Desired encodings... 549 $encodings=array( 550 'head_encoding' => 'quoted-printable', 551 'text_encoding' => 'base64', 552 'html_encoding' => 'base64', 553 'html_charset' => 'utf-8', 554 'text_charset' => 'utf-8', 555 'head_charset' => 'utf-8' 556 ); 557 //encode the body 558 $body = $mime->get($encodings); 559 //encode the headers. 560 $headers = $mime->headers($headers, true); 561 $to = implode(',', array_filter(array($headers['To'], $headers['Cc'], 562 $headers['Bcc']))); 563 // Cache smtp connections made during this request 564 static $smtp_connections = array(); 565 if(($smtp=$this->getSMTPInfo())) { //Send via SMTP 566 $key = sprintf("%s:%s:%s", $smtp['host'], $smtp['port'], 567 $smtp['username']); 568 if (!isset($smtp_connections[$key])) { 569 $mail = mail::factory('smtp', array( 570 'host' => $smtp['host'], 571 'port' => $smtp['port'], 572 'auth' => $smtp['auth'], 573 'username' => $smtp['username'], 574 'password' => $smtp['password'], 575 'timeout' => 20, 576 'debug' => false, 577 'persist' => true, 578 )); 579 if ($mail->connect()) 580 $smtp_connections[$key] = $mail; 581 } 582 else { 583 // Use persistent connection 584 $mail = $smtp_connections[$key]; 585 } 586 587 $result = $mail->send($to, $headers, $body); 588 if(!PEAR::isError($result)) 589 return $messageId; 590 591 // Force reconnect on next ->send() 592 unset($smtp_connections[$key]); 593 594 $alert=_S("Unable to email via SMTP") 595 .sprintf(":%1\$s:%2\$d [%3\$s]\n\n%4\$s\n", 596 $smtp['host'], $smtp['port'], $smtp['username'], $result->getMessage()); 597 $this->logError($alert); 598 } 599 600 //No SMTP or it failed....use php's native mail function. 601 $args = array(); 602 if (isset($options['from_address'])) 603 $args[] = '-f '.$options['from_address']; 604 elseif ($this->getEmail()) 605 $args = array('-f '.$this->getEmail()->getEmail()); 606 $mail = mail::factory('mail', $args); 607 $to = $headers['To']; 608 $result = $mail->send($to, $headers, $body); 609 if(!PEAR::isError($result)) 610 return $messageId; 611 612 $alert=_S("Unable to email via php mail function") 613 .sprintf(":%1\$s\n\n%2\$s\n", 614 $to, $result->getMessage()); 615 $this->logError($alert); 616 return false; 617 } 618 619 function logError($error) { 620 global $ost; 621 //NOTE: Admin alert override - don't email when having email trouble! 622 $ost->logError(_S('Mailer Error'), $error, false); 623 } 624 625 /******* Static functions ************/ 626 627 //Emails using native php mail function - if DB connection doesn't exist. 628 //Don't use this function if you can help it. 629 function sendmail($to, $subject, $message, $from, $options=null) { 630 $mailer = new Mailer(null, array('notice'=>true, 'nobounce'=>true)); 631 $mailer->setFromAddress($from); 632 return $mailer->send($to, $subject, $message, $options); 633 } 634} 635?> 636