diff --git a/include/class.client.php b/include/class.client.php index 097f2d2b19ecd969f528be838a17992efbc380fd..771f230b0b20b4118d6c6344193e95929ba79738 100644 --- a/include/class.client.php +++ b/include/class.client.php @@ -15,7 +15,8 @@ **********************************************************************/ require_once INCLUDE_DIR.'class.user.php'; -abstract class TicketUser { +abstract class TicketUser +implements EmailContact { static private $token_regex = '/^(?P<type>\w{1})(?P<algo>\d+)x(?P<hash>.*)$/i'; @@ -54,6 +55,9 @@ abstract class TicketUser { } + function getId() { return ($this->user) ? $this->user->getId() : null; } + function getEmail() { return ($this->user) ? $this->user->getEmail() : null; } + function sendAccessLink() { global $ost; @@ -421,4 +425,7 @@ class ClientAccount extends UserAccount { } } +// Used by the email system +interface EmailContact { +} ?> diff --git a/include/class.mailer.php b/include/class.mailer.php index a3093c203a14eac9f8e7a2082830f715352b2128..1063ffcbaa993d7c5cbf6527535637825187e33d 100644 --- a/include/class.mailer.php +++ b/include/class.mailer.php @@ -90,6 +90,146 @@ class Mailer { $this->addAttachment($a); } + /** + * getMessageId + * + * Generates a unique message ID for an outbound message. Optionally, + * the recipient can be used to create a tag for the message ID where + * the user-id and thread-entry-id are encoded in the message-id so + * the message can be threaded if it is replied to without any other + * indicator of the thread to which it belongs. This tag is signed with + * the secret-salt of the installation to guard against false positives. + * + * Parameters: + * $recipient - (EmailContact|null) recipient of the message. The ID of + * the recipient is placed in the message id TAG section so it can + * be recovered if the email replied to directly by the end user. + * $options - (array) - options passed to ::send(). If it includes a + * 'thread' element, the threadId will be recorded in the TAG + * + * Returns: + * (string) - email message id, with leading and trailing <> chars. See + * the format below for the structure. + * + * Format: + * VA-B-C-D, with dash separators and A-D explained below: + * + * V: Version code of the generated Message-Id + * A: Predictable random code — used for loop detection + * B: Random data for unique identifier + * Version Code: A (at char position 10) + * C: TAG: Base64(Pack(userid, entryId, type)), = chars discarded + * D: Signature: + * '@' + Signed Tag value, last 10 chars from + * HMAC(sha1, tag+rand, SECRET_SALT) + * -or- Original From email address + */ + function getMessageId($recipient, $options=array(), $version='A') { + $tag = ''; + $rand = Misc::randCode(9, + // RFC822 specifies the LHS of the addr-spec can have any char + // except the specials — ()<>@,;:\".[], dash is reserved as the + // section separator, and + is reserved for historical reasons + 'abcdefghiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_='); + $sig = $this->getEmail()?$this->getEmail()->getEmail():'@osTicketMailer'; + if ($recipient instanceof EmailContact) { + // Create a tag for the outbound email + $tag = pack('VVa', + $recipient->getId(), + (isset($options['thread']) && $options['thread'] instanceof ThreadEntry) + ? $options['thread']->getId() : 0, + ($recipient instanceof Staff ? 'S' + : ($recipient instanceof TicketOwner ? 'U' + : ($recipient instanceof Collaborator ? 'C' + : '?'))) + ); + $tag = str_replace('=','',base64_encode($tag)); + // Sign the tag with the system secret salt + $sig = '@' . substr(hash_hmac('sha1', $tag.$rand, SECRET_SALT), -10); + } + return sprintf('<A%s-%s-%s-%s>', + static::getSystemMessageIdCode(), + $rand, $tag, $sig); + } + + /** + * decodeMessageId + * + * Decodes a message-id generated by osTicket using the ::getMessageId() + * method of this class. This will digest the received message-id token + * and return an array with some information about it. + * + * Parameters: + * $mid - (string) message-id from an email Message-Id, In-Reply-To, and + * References header. + * + * Returns: + * (array) of information containing all or some of the following keys + * 'loopback' - (bool) true or false if the message originated by + * this osTicket installation. + * 'version' - (string|FALSE) version code of the message id + * 'code' - (string) unique but predictable help desk message-id + * 'id' - (string) random characters serving as the unique id + * 'threadId' - (int) thread-id from which the message originated + * 'staffId' - (int|null) staff the email was originally sent to + * 'userId' - (int|null) user the email was originally sent to + * 'userClass' - (string) class of user the email was sent to + * 'U' - TicketOwner + * 'S' - Staff + * 'C' - Collborator + * '?' - Something else + */ + static function decodeMessageId($mid) { + // Drop <> tokens + $mid = trim($mid, '<> '); + // Drop email domain on rhs + list($lhs, $sig) = explode('@', $mid, 2); + // LHS should be tokenized by '-' + $parts = explode('-', $lhs); + + $rv = array('loopback' => false, 'version' => false); + + // There should be at least two tokens if the message was sent by + // this system. Otherwise, there's nothing to be detected + if (count($parts) < 2) + return $rv; + + // Detect the MessageId version, which should be the tenth char of + // the second segment + $rv['version'] = @$parts[0][0]; + switch ($rv['version']) { + case 'A': + default: + list($rv['code'], $rv['id'], $tag) = $parts; + // Drop the leading version code + $rv['code'] = substr($rv['code'], 1); + // Verify tag signature + $chksig = substr(hash_hmac('sha1', $tag.$rv['id'], SECRET_SALT), -10); + if ($tag && $sig == $chksig && ($tag = base64_decode($tag))) { + // Find user and ticket id + $rv += unpack('Vuid/VthreadId/auserClass', $tag); + // Attempt to make the user-id more specific + $classes = array( + 'S' => 'staffId', 'U' => 'userId' + ); + if (isset($classes[$rv['userClass']])) + $rv[$classes[$rv['userClass']]] = $rv['uid']; + } + // Round-trip detection - the first section is the local + // system's message-id code + $rv['loopback'] = (0 === strcasecmp($rv['code'], + static::getSystemMessageIdCode())); + break; + } + return $rv; + } + + static function getSystemMessageIdCode() { + return substr(str_replace('+', '=', + base64_encode(md5('mail'.SECRET_SALT, true))), + 0, 6); + } + function send($to, $subject, $message, $options=null) { global $ost, $cfg; @@ -97,16 +237,24 @@ class Mailer { require_once (PEAR_DIR.'Mail.php'); // PEAR Mail package require_once (PEAR_DIR.'Mail/mime.php'); // PEAR Mail_Mime packge + $messageId = $this->getMessageId($to, $options); + + if (is_object($to) && is_callable(array($to, 'getEmail'))) { + // Add personal name if available + if (is_callable(array($to, 'getName'))) { + $to = sprintf('"%s" <%s>', + $to->getName()->getOriginal(), $to->getEmail() + ); + } + else { + $to = $to->getEmail(); + } + } + //do some cleanup $to = preg_replace("/(\r\n|\r|\n)/s",'', trim($to)); $subject = preg_replace("/(\r\n|\r|\n)/s",'', trim($subject)); - /* Message ID - generated for each outgoing email */ - $messageId = sprintf('<%s-%s-%s>', - substr(md5('mail'.SECRET_SALT), -9), - Misc::randCode(9), - ($this->getEmail()?$this->getEmail()->getEmail():'@osTicketMailer')); - $headers = array ( 'From' => $this->getFromAddress(), 'To' => $to, @@ -155,6 +303,26 @@ class Mailer { } } + // Make the best effort to add In-Reply-To and References headers + if (isset($options['thread']) + && $options['thread'] instanceof ThreadEntry + ) { + $headers += array('References' => $options['thread']->getEmailReferences()); + if ($irt = $options['thread']->getEmailMessageId()) { + // This is an response from an email, like and autoresponse. + // Web posts will not have a email message-id + $headers += array('In-Reply-To' => $irt); + } + elseif ($parent = $options['thread']->getParent()) { + // Use the parent item as the email information source. This + // will apply for staff replies + $headers += array( + 'In-Reply-To' => $parent->getEmailMessageId(), + 'References' => $parent->getEmailReferences(), + ); + } + } + // Use Mail_mime default initially $eol = null; diff --git a/include/class.staff.php b/include/class.staff.php index 10404bf80d384546268ef82bf3f157db2528a116..27d1874091a26bf6645c346bd574eda625f7da15 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -22,7 +22,8 @@ include_once(INCLUDE_DIR.'class.passwd.php'); include_once(INCLUDE_DIR.'class.user.php'); include_once(INCLUDE_DIR.'class.auth.php'); -class Staff extends AuthenticatedUser { +class Staff extends AuthenticatedUser +implements EmailContact { var $ht; var $id; diff --git a/include/class.thread.php b/include/class.thread.php index dc8a559e1158ca0f8d23d741e6a6e61de5b1e838..6ec237888567b074704e045bb80649727269e8db 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -299,6 +299,11 @@ Class ThreadEntry { return $this->ht['pid']; } + function getParent() { + if ($this->getPid()) + return ThreadEntry::lookup($this->getPid()); + } + function getType() { return $this->ht['thread_type']; } @@ -370,32 +375,11 @@ Class ThreadEntry { $headers = self::getEmailHeaderArray(); if (isset($headers['References']) && $headers['References']) $references = $headers['References']." "; - if ($include_mid) - $references .= $this->getEmailMessageId(); + if ($include_mid && ($mid = $this->getEmailMessageId())) + $references .= $mid; return $references; } - function getTaggedEmailReferences($prefix, $refId) { - - $ref = "+$prefix".Base32::encode(pack('VV', $this->getId(), $refId)); - - $mid = substr_replace($this->getEmailMessageId(), - $ref, strpos($this->getEmailMessageId(), '@'), 0); - - return sprintf('%s %s', $this->getEmailReferences(false), $mid); - } - - function getEmailReferencesForUser($user) { - return $this->getTaggedEmailReferences('u', - ($user instanceof Collaborator) - ? $user->getUserId() - : $user->getId()); - } - - function getEmailReferencesForStaff($staff) { - return $this->getTaggedEmailReferences('s', $staff->getId()); - } - function getUIDFromEmailReference($ref) { $info = unpack('Vtid/Vuid', @@ -673,8 +657,8 @@ Class ThreadEntry { // where code is a predictable string based on the SECRET_SALT of // this osTicket installation. If this incoming mail matches the // code, then it very likely originated from this system and looped - @list($code) = explode('-', $mailinfo['mid'], 2); - if (0 === strcasecmp(ltrim($code, '<'), substr(md5('mail'.SECRET_SALT), -9))) { + $msgId_info = Mailer::decodeMessageId($mailinfo['mid']); + if ($msgId_info['loopback']) { // This mail was sent by this system. It was received due to // some kind of mail delivery loop. It should not be considered // a response to an existing thread entry @@ -904,6 +888,25 @@ Class ThreadEntry { return $t; } } + // Attempt to detect the ticket and user ids from the + // message-id header. If the message originated from + // osTicket, the Mailer class can break it apart. If it came + // from this help desk, the 'loopback' property will be set + // to true. + $mid_info = Mailer::decodeMessageId($mid); + if ($mid_info['loopback'] && isset($mid_info['uid']) + && @$mid_info['threadId'] + && ($t = ThreadEntry::lookup($mid_info['threadId'])) + ) { + if (@$mid_info['userId']) { + $mailinfo['userId'] = $mid_info['userId']; + } + elseif (@$mid_info['staffId']) { + $mailinfo['staffId'] = $mid_info['staffId']; + } + // ThreadEntry was positively identified + return $t; + } } // Second best case — found a thread but couldn't identify the // user from the header. Return the first thread entry matched @@ -1112,10 +1115,7 @@ Class ThreadEntry { return false; } - // Email message id (required for all thread posts) - if (!isset($vars['mid'])) - $vars['mid'] = sprintf('<%s@%s>', Misc::randCode(24), - substr(md5($cfg->getUrl()), -10)); + // Email message id $entry->saveEmailInfo($vars); // Inline images (attached to the draft) diff --git a/include/class.ticket.php b/include/class.ticket.php index 35b2526610d40aab28b5c20703b7b543ceef871e..2619c625be1c0b345f97813f320e4fdc2a4ba536 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -989,7 +989,7 @@ class Ticket { 'signature' => ($dept && $dept->isPublic())?$dept->getSignature():'') ); - $email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body'], + $email->sendAutoReply($this->getOwner(), $msg['subj'], $msg['body'], null, $options); } @@ -1035,7 +1035,7 @@ class Ticket { foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[] = $staff->getEmail(); } } @@ -1064,7 +1064,7 @@ class Ticket { $msg = $this->replaceVars($msg->asArray(), array('signature' => ($dept && $dept->isPublic())?$dept->getSignature():'')); - $email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body']); + $email->sendAutoReply($this->getOwner(), $msg['subj'], $msg['body']); } $user = $this->getOwner(); @@ -1126,9 +1126,8 @@ class Ticket { 'thread' => $entry); foreach ($recipients as $recipient) { if ($uid == $recipient->getUserId()) continue; - $options['references'] = $entry->getEmailReferencesForUser($recipient); $notice = $this->replaceVars($msg, array('recipient' => $recipient)); - $email->send($recipient->getEmail(), $notice['subj'], $notice['body'], $attachments, + $email->send($recipient, $notice['subj'], $notice['body'], $attachments, $options); } @@ -1194,9 +1193,8 @@ class Ticket { $options = array( 'inreplyto'=>$message->getEmailMessageId(), - 'references' => $message->getEmailReferencesForUser($user), 'thread'=>$message); - $email->sendAutoReply($user->getEmail(), $msg['subj'], $msg['body'], + $email->sendAutoReply($user, $msg['subj'], $msg['body'], null, $options); } } @@ -1259,7 +1257,7 @@ class Ticket { foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[] = $staff->getEmail(); } } @@ -1308,7 +1306,7 @@ class Ticket { foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null); $sentlist[] = $staff->getEmail(); } @@ -1511,7 +1509,7 @@ class Ticket { foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[] = $staff->getEmail(); } } @@ -1746,7 +1744,7 @@ class Ticket { foreach( $recipients as $k=>$staff) { if(!$staff || !$staff->getEmail() || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[] = $staff->getEmail(); } } @@ -1807,7 +1805,7 @@ class Ticket { 'inreplyto'=>$response->getEmailMessageId(), 'references'=>$response->getEmailReferences(), 'thread'=>$response); - $email->sendAutoReply($this->getEmail(), $msg['subj'], $msg['body'], $attachments, + $email->sendAutoReply($this, $msg['subj'], $msg['body'], $attachments, $options); } @@ -1869,7 +1867,7 @@ class Ticket { $variables + array('recipient' => $this->getOwner())); $attachments = $cfg->emailAttachments()?$response->getAttachments():array(); - $email->send($this->getEmail(), $msg['subj'], $msg['body'], $attachments, + $email->send($this->getOwner(), $msg['subj'], $msg['body'], $attachments, $options); } @@ -2016,7 +2014,7 @@ class Ticket { ) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); - $email->sendAlert($staff->getEmail(), $alert['subj'], $alert['body'], null, $options); + $email->sendAlert($staff, $alert['subj'], $alert['body'], null, $options); $sentlist[$staff->getEmail()] = 1; } } @@ -2891,7 +2889,7 @@ class Ticket { 'references' => $references, 'thread' => $ticket->getLastMessage() ); - $email->send($ticket->getEmail(), $msg['subj'], $msg['body'], $attachments, + $email->send($ticket->getOwner(), $msg['subj'], $msg['body'], $attachments, $options); }