diff --git a/include/api.tickets.php b/include/api.tickets.php index 32b7d6feb9ec544f015426b3790c71b61f7af815..d70eda638a31f69bc9841d47d0714c127232a9ef 100644 --- a/include/api.tickets.php +++ b/include/api.tickets.php @@ -151,25 +151,29 @@ class TicketApiController extends ApiController { if (!$data) $data = $this->getEmailRequest(); + $seen = false; if (($entry = ThreadEntry::lookupByEmailHeaders($data, $seen)) - && ($thread = $entry->getThread()) - && ($t = $thread->getObject()) - && (!$t instanceof Ticket || ( - $vars['staffId'] - || !$t->isClosed() - || $t->isReopenable() - )) && ($message = $entry->postEmail($data)) ) { if ($message instanceof ThreadEntry) { return $message->getThread()->getObject(); } - else if ($message) { + else if ($seen) { // Email has been processed previously - return $t; + return $entry->getThread()->getObject(); } } + // Allow continuation of thread without initial message or note + elseif (($thread = Thread::lookupByEmailHeaders($data)) + && ($message = $thread->postEmail($data)) + ) { + return $thread->getObject(); + } + + // All emails which do not appear to be part of an existing thread + // will always create new "Tickets". All other objects will need to + // be created via the web interface or the API return $this->createTicket($data); } diff --git a/include/class.mailer.php b/include/class.mailer.php index a99fb7a6acefd2058ec53c2dbc5b6c11d9bd5ae3..e3b40c6b2483de759a8a251aed9344f813a45039 100644 --- a/include/class.mailer.php +++ b/include/class.mailer.php @@ -118,23 +118,24 @@ class Mailer { * '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. + * (string) - email message id, without leading and trailing <> chars. + * See the Format below for the structure. * * Format: - * VA-B-C-D, with dash separators and A-D explained below: + * VA-B-C, with dash separators and A-C 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 + * A: Predictable random code — used for loop detection (sysid) + * B: Random data for unique identifier (rand) + * C: TAG: Base64(Pack(userid, entryId, threadId, type, Signature)), + * '=' chars discarded + * where Signature is: + * Signed Tag value, last 5 chars from + * HMAC(sha1, Tag + rand + sysid, SECRET_SALT), + * where Tag is: + * pack(userId, entryId, threadId, type) */ - function getMessageId($recipient, $options=array(), $version='A') { + function getMessageId($recipient, $options=array(), $version='B') { $tag = ''; $rand = Misc::randCode(9, // RFC822 specifies the LHS of the addr-spec can have any char @@ -142,13 +143,14 @@ class Mailer { // section separator, and + is reserved for historical reasons 'abcdefghiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_='); $sig = $this->getEmail()?$this->getEmail()->getEmail():'@osTicketMailer'; + $sysid = static::getSystemMessageIdCode(); if ($recipient instanceof EmailContact) { // Create a tag for the outbound email $entry = (isset($options['thread']) && $options['thread'] instanceof ThreadEntry) ? $options['thread'] : false; $thread = $entry ? $entry->getThread() : (isset($options['thread']) && $options['thread'] instanceof Thread - ? $options['thread']->getId() : false); + ? $options['thread'] : false); $tag = pack('VVVa', $recipient->getId(), $entry ? $entry->getId() : 0, @@ -158,13 +160,12 @@ class Mailer { : ($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); + $tag .= substr(hash_hmac('sha1', $tag.$rand.$sysid, SECRET_SALT, true), -5); + $tag = str_replace('=','',base64_encode($tag)); } - return sprintf('<B%s-%s-%s-%s>', - static::getSystemMessageIdCode(), - $rand, $tag, $sig); + return sprintf('B%s-%s-%s-%s', + $sysid, $rand, $tag, $sig); } /** @@ -210,36 +211,63 @@ class Mailer { 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]; - $format = 'Vuid/VentryId/auserClass'; - switch ($rv['version']) { - case 'B': - $format = 'Vuid/VentryId/VthreadId/auserClass'; - 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); + $decoders = array( + 'A' => function($id, $tag) use ($sig) { + // Old format was VA-B-C-D@sig, where C was the packed tag and D + // was blank + $format = 'Vuid/VentryId/auserClass'; + $chksig = substr(hash_hmac('sha1', $tag.$id, SECRET_SALT), -10); if ($tag && $sig == $chksig && ($tag = base64_decode($tag))) { // Find user and ticket id - $rv += unpack($format, $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']; + return unpack($format, $tag); } - // Round-trip detection - the first section is the local - // system's message-id code - $rv['loopback'] = (0 === strcasecmp($rv['code'], - static::getSystemMessageIdCode())); - break; - } + return false; + }, + 'B' => function($id, $tag) { + $format = 'Vuid/VentryId/VthreadId/auserClass/a*sig'; + if ($tag && ($tag = base64_decode($tag))) { + $info = unpack($format, $tag); + $sysid = static::getSystemMessageIdCode(); + $shorttag = substr($tag, 0, 13); + $chksig = substr(hash_hmac('sha1', $shorttag.$id.$sysid, + SECRET_SALT, true), -5); + if ($chksig == $info['sig']) { + return $info; + } + } + return false; + }, + ); + + // Detect the MessageId version, which should be the first char + $rv['version'] = @$parts[0][0]; + if (!isset($decoders[$rv['version']])) + // invalid version code + return null; + + // Drop the leading version code + list($rv['code'], $rv['id'], $tag) = $parts; + $rv['code'] = substr($rv['code'], 1); + + // Verify tag signature and unpack the tag + $info = $decoders[$rv['version']]($rv['id'], $tag); + if ($info === false) + return $rv; + + $rv += $info; + + // 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())); + return $rv; } @@ -279,7 +307,7 @@ class Mailer { 'To' => $to, 'Subject' => $subject, 'Date'=> date('D, d M Y H:i:s O'), - 'Message-ID' => $messageId, + 'Message-ID' => "<{$messageId}>", 'X-Mailer' =>'osTicket Mailer', ); @@ -326,7 +354,8 @@ class Mailer { if (isset($options['thread']) && $options['thread'] instanceof ThreadEntry ) { - $headers += array('References' => $options['thread']->getEmailReferences()); + if ($references = $options['thread']->getEmailReferences()) + $headers += array('References' => $references); if ($irt = $options['thread']->getEmailMessageId()) { // This is an response from an email, like and autoresponse. // Web posts will not have a email message-id @@ -367,17 +396,16 @@ class Mailer { // then assume that it needs html processing to create a valid text // body $isHtml = true; - $mid_token = (isset($options['thread'])) - ? $options['thread']->asMessageId($to) : ''; if (!(isset($options['text']) && $options['text'])) { $tag = ''; if ($cfg && $cfg->stripQuotedReply() && (!isset($options['reply-tag']) || $options['reply-tag'])) - $tag = $cfg->getReplySeparator() . '<br/><br/>'; - $message = "<div style=\"display:none\" - data-mid=\"$mid_token\">$tag</div>$message"; + $tag = '<div>'.$cfg->getReplySeparator() . '<br/><br/></div>'; + // Embed the data-mid in such a way that it should be included + // in a response + $message = "<div data-mid=\"$messageId\">{$tag}{$message}</div>"; $txtbody = rtrim(Format::html2text($message, 90, false)) - . ($mid_token ? "\nRef-Mid: $mid_token\n" : ''); + . ($messageId ? "\nRef-Mid: $messageId\n" : ''); $mime->setTXTBody($txtbody); } else { diff --git a/include/class.mailfetch.php b/include/class.mailfetch.php index 04475abc01cc10a1135c304cdc8627192e05a0dd..4e0e3c43059aac9c100ea390ffd3622551d5aba6 100644 --- a/include/class.mailfetch.php +++ b/include/class.mailfetch.php @@ -722,13 +722,6 @@ class MailFetcher { $seen = false; if (($entry = ThreadEntry::lookupByEmailHeaders($vars, $seen)) - && ($thread = $entry->getThread()) - && ($t = $thread->getObject()) - && (!$t instanceof Ticket || ( - $vars['staffId'] - || !$t->isClosed() - || $t->isReopenable() - )) && ($message = $entry->postEmail($vars)) ) { if (!$message instanceof ThreadEntry) @@ -736,13 +729,23 @@ class MailFetcher { return $message; // NOTE: This might not be a "ticket" $ticket = $message->getThread()->getObject(); - } elseif ($seen) { + } + elseif ($seen) { // Already processed, but for some reason (like rejection), no // thread item was created. Ignore the email return true; - } elseif (($ticket=Ticket::create($vars, $errors, 'Email'))) { + } + // Allow continuation of thread without initial message or note + elseif (($thread = Thread::lookupByEmailHeaders($vars)) + && ($message = $entry->postEmail($vars)) + ) { + // NOTE: This might not be a "ticket" + $ticket = $thread->getObject(); + } + elseif (($ticket=Ticket::create($vars, $errors, 'Email'))) { $message = $ticket->getLastMessage(); - } else { + } + else { //Report success if the email was absolutely rejected. if(isset($errors['errno']) && $errors['errno'] == 403) { // Never process this email again! diff --git a/include/class.thread.php b/include/class.thread.php index b8b5badebdb3f37ce0a30c7e86cb3ba51abdfe2c..0dba7135cf4c7b455334a91e3f3f1c3ef568ae7f 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -108,9 +108,19 @@ class Thread extends VerySimpleModel { // | Message (M) | Response (R) | From: Staff | // +------------------+-------------------+-------------+ - if (!$object = $this->getObject()) + if (!$object = $this->getObject()) { // How should someone find this thread? return false; + } + elseif ($object instanceof Ticket && ( + !$mailinfo['staffId'] + && $object->isClosed() + && !$object->isReopenable() + )) { + // Ticket is closed, not reopenable, and email was not submitted + // by an agent. Email cannot be submitted + return false; + } // Mail sent by this system will have a message-id format of // <code-random-mailbox@domain.tld> @@ -241,6 +251,71 @@ class Thread extends VerySimpleModel { return $deleted; } + /** + * Function: lookupByEmailHeaders + * + * Attempt to locate a thread by the email headers. It should be + * considered a secondary lookup to ThreadEntry::lookupByEmailHeaders(), + * which should find an actual thread entry, which should be possible + * for all email communcation which is associated with a thread entry. + * The only time where this is useful is for threads which triggered + * email communication without a thread entry, for instance, like + * tickets created without an initial message. + */ + function lookupByEmailHeaders(&$mailinfo) { + $possibles = array(); + foreach (array('in-reply-to', 'references') as $header) { + $matches = array(); + if (!isset($mailinfo[$header]) || !$mailinfo[$header]) + continue; + // Header may have multiple entries (usually separated by + // spaces ( ) + elseif (!preg_match_all('/<[^>@]+@[^>]+>/', $mailinfo[$header], + $matches)) + continue; + + // The References header will have the most recent message-id + // (parent) on the far right. + // @see rfc 1036, section 2.2.5 + // @see http://www.jwz.org/doc/threading.html + $possibles = array_merge($possibles, array_reverse($matches[0])); + } + + // Add the message id if it is embedded in the body + $match = array(); + if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`', + $mailinfo['message'], $match) + && !in_array($match[1], $possibles) + ) { + $possibles[] = $match[1]; + } + + foreach ($possibles as $mid) { + // 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 = Thread::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; + } + } + + return null; + } + + function delete() { //Self delete @@ -286,7 +361,7 @@ class ThreadEntry extends VerySimpleModel { 'select_related' => array('staff', 'user', 'email_info'), 'joins' => array( 'thread' => array( - 'constraint' => array('thread_id' => 'ThreadModel.id'), + 'constraint' => array('thread_id' => 'Thread.id'), ), 'parent' => array( 'constraint' => array('pid' => 'ThreadEntry.id'), @@ -730,6 +805,7 @@ class ThreadEntry extends VerySimpleModel { return $entry; } + $possibles = array(); foreach (array('in-reply-to', 'references') as $header) { $matches = array(); if (!isset($mailinfo[$header]) || !$mailinfo[$header]) @@ -744,59 +820,71 @@ class ThreadEntry extends VerySimpleModel { // (parent) on the far right. // @see rfc 1036, section 2.2.5 // @see http://www.jwz.org/doc/threading.html - $thread = null; - foreach (array_reverse($matches[0]) as $mid) { - //Try to determine if it's a reply to a tagged email. - $ref = null; - if (strpos($mid, '+')) { - list($left, $right) = explode('@',$mid); - list($left, $ref) = explode('+', $left); - $mid = "$left@$right"; + $possibles = array_merge($possibles, array_reverse($matches[0])); + } + + // Add the message id if it is embedded in the body + $match = array(); + if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`', + $mailinfo['message'], $match) + && !in_array($match[1], $possibles) + ) { + $possibles[] = $match[1]; + } + + $thread = null; + foreach ($possibles as $mid) { + //Try to determine if it's a reply to a tagged email. + $ref = null; + if (strpos($mid, '+')) { + list($left, $right) = explode('@',$mid); + list($left, $ref) = explode('+', $left); + $mid = "$left@$right"; + } + $entries = ThreadEntry::objects() + ->filter(array('email_info__mid' => $mid)); + foreach ($entries as $t) { + // Capture the first match thread item + if (!$thread) + $thread = $t; + // We found a match - see if we can ID the user. + // XXX: Check access of ref is enough? + if ($ref && ($uid = $t->getUIDFromEmailReference($ref))) { + if ($ref[0] =='s') //staff + $mailinfo['staffId'] = $uid; + else // user or collaborator. + $mailinfo['userId'] = $uid; + + // Best possible case — found the thread and the + // user + return $t; } - $possibles = ThreadEntry::objects() - ->filter(array('email_info__mid' => $mid)); - foreach ($possibles as $t) { - // Capture the first match thread item - if (!$thread) - $thread = $t; - // We found a match - see if we can ID the user. - // XXX: Check access of ref is enough? - if ($ref && ($uid = $t->getUIDFromEmailReference($ref))) { - if ($ref[0] =='s') //staff - $mailinfo['staffId'] = $uid; - else // user or collaborator. - $mailinfo['userId'] = $uid; - - // Best possible case — found the thread and the - // user - 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['entryId'] + && ($t = ThreadEntry::lookup($mid_info['entryId'])) + && ($t->thread_id == $mid_info['threadId']) + ) { + if (@$mid_info['userId']) { + $mailinfo['userId'] = $mid_info['userId']; } - // 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['entryId'] - && ($t = ThreadEntry::lookup($mid_info['entryId'])) - ) { - if (@$mid_info['userId']) { - $mailinfo['userId'] = $mid_info['userId']; - } - elseif (@$mid_info['staffId']) { - $mailinfo['staffId'] = $mid_info['staffId']; - } - // ThreadEntry was positively identified - return $t; + 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 - if ($thread) - return $thread; } + // Second best case — found a thread but couldn't identify the + // user from the header. Return the first thread entry matched + if ($thread) + return $thread; // Search for ticket by the [#123456] in the subject line // This is the last resort - emails must match to avoid message @@ -825,6 +913,8 @@ class ThreadEntry extends VerySimpleModel { } // Search for the message-id token in the body + // *DEPRECATED* the current algo on outgoing mail will use + // Mailer::getMessageId as the message id tagged here if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`', $mailinfo['message'], $match)) if ($thread = ThreadEntry::lookupByRefMessageId($match[1], @@ -836,7 +926,9 @@ class ThreadEntry extends VerySimpleModel { /** * Find a thread entry from a message-id created from the - * ::asMessageId() method + * ::asMessageId() method. + * + * *DEPRECATED* use Mailer::decodeMessageId() instead */ function lookupByRefMessageId($mid, $from) { $mid = trim($mid, '<>'); @@ -854,36 +946,7 @@ class ThreadEntry extends VerySimpleModel { if (!$thread) return false; - if (0 === strcasecmp($thread->asMessageId($from, $ver), $mid)) - return $thread; - } - - /** - * Get an email message-id that can be used to represent this thread - * entry. The same message-id can be passed to ::lookupByRefMessageId() - * to find this thread entry - * - * Formats: - * Initial (version <null>) - * <$:b32(thread-id)$:md5(to-addr.ticket-num.ticket-id)@:md5(url)> - * thread-id - thread-id, little-endian INT, packed - * :b32() - base32 encoded - * to-addr - individual email recipient - * ticket-num - external ticket number - * ticket-id - internal ticket id - * :md5() - last 10 hex chars of MD5 sum - * url - helpdesk URL - */ - function asMessageId($to, $version=false) { - global $ost; - - $domain = md5($ost->getConfig()->getURL()); - $ticket = $this->getThread()->getObject(); - return sprintf('$%s$%s@%s', - base64_encode(pack('V', $this->getId())), - substr(md5($to . $ticket->getNumber() . $ticket->getId()), -10), - substr($domain, -10) - ); + return $thread; } //new entry ... we're trusting the caller to check validity of the data. diff --git a/include/class.ticket.php b/include/class.ticket.php index 41cc699305670f58ecbdabbaa4aa60abb98b50d7..a7a91190af8d205e386d644f2624a84359fe574f 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -2324,13 +2324,14 @@ implements RestrictedAccess, Threadable { // Threadable interface function postThreadEntry($type, $vars) { + $errors = array(); switch ($type) { case 'M': return $this->postMessage($vars, $vars['origin']); case 'N': - return $this->postNote($vars); + return $this->postNote($vars, $errors); case 'R': - return $this->postReply($vars); + return $this->postReply($vars, $errors); } } @@ -3262,7 +3263,7 @@ implements RestrictedAccess, Threadable { $references[] = $response->getEmailMessageId(); $options = array( 'references' => $references, - 'thread' => $message, + 'thread' => $message ?: $ticket->getThread(), ); $email->send($ticket->getOwner(), $msg['subj'], $msg['body'], $attachments, $options);