<?php /********************************************************************* class.filter.php Ticket Filter Peter Rotich <peter@osticket.com> Copyright (c) 2006-2013 osTicket http://www.osticket.com Released under the GNU General Public License WITHOUT ANY WARRANTY. See LICENSE.TXT for details. vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ class Filter { var $id; var $ht; static $match_types = array( 'User Information' => array( 'name' => 'Name', 'email' => 'Email', ), 'Email Meta-Data' => array( 'reply-to' => 'Reply-To Email', 'reply-to-name' => 'Reply-To Name', 'addressee' => 'Addressee (To and Cc)', ), ); function Filter($id) { $this->id=0; $this->load($id); } function load($id=0) { if(!$id && !($id=$this->getId())) return false; $sql='SELECT filter.*,count(rule.id) as rule_count ' .' FROM '.FILTER_TABLE.' filter ' .' LEFT JOIN '.FILTER_RULE_TABLE.' rule ON(rule.filter_id=filter.id) ' .' WHERE filter.id='.db_input($id) .' GROUP BY filter.id'; if(!($res=db_query($sql)) || !db_num_rows($res)) return false; $this->ht=db_fetch_array($res); $this->id=$this->ht['id']; return true; } function reload() { return $this->load($this->getId()); } function getId() { return $this->id; } function getTarget() { return $this->ht['target']; } function getName() { return $this->ht['name']; } function getNotes() { return $this->ht['notes']; } function getInfo() { return $this->ht; } function getNumRules() { return $this->ht['rule_count']; } function getExecOrder() { return $this->ht['execorder']; } function getEmailId() { return $this->ht['email_id']; } function isActive() { return ($this->ht['isactive']); } function isSystemBanlist() { return !strcasecmp($this->getName(),'SYSTEM BAN LIST'); } function getDeptId() { return $this->ht['dept_id']; } function getPriorityId() { return $this->ht['priority_id']; } function getSLAId() { return $this->ht['sla_id']; } function getStaffId() { return $this->ht['staff_id']; } function getTeamId() { return $this->ht['team_id']; } function getCannedResponse() { return $this->ht['canned_response_id']; } function stopOnMatch() { return ($this->ht['stop_on_match']); } function matchAllRules() { return ($this->ht['match_all_rules']); } function rejectOnMatch() { return ($this->ht['reject_ticket']); } function useReplyToEmail() { return ($this->ht['use_replyto_email']); } function disableAlerts() { return ($this->ht['disable_autoresponder']); } function sendAlerts() { return (!$this->disableAlerts()); } function getRules() { if (!$this->ht['rules']) { $rules=array(); //We're getting the rules...live because it gets cleared on update. $sql='SELECT * FROM '.FILTER_RULE_TABLE.' WHERE filter_id='.db_input($this->getId()); if(($res=db_query($sql)) && db_num_rows($res)) { while($row=db_fetch_array($res)) $rules[]=array('w'=>$row['what'],'h'=>$row['how'],'v'=>$row['val']); } $this->ht['rules'] = $rules; } return $this->ht['rules']; } function getFlatRules() { //Format used on html... I'm ashamed $info=array(); if(($rules=$this->getRules())) { foreach($rules as $k=>$rule) { $i=$k+1; $info["rule_w$i"]=$rule['w']; $info["rule_h$i"]=$rule['h']; $info["rule_v$i"]=$rule['v']; } } return $info; } function addRule($what, $how, $val,$extra=array()) { $errors = array(); $rule= array_merge($extra,array('what'=>$what, 'how'=>$how, 'val'=>$val)); $rule['filter_id']=$this->getId(); return FilterRule::create($rule,$errors); } function removeRule($what, $how, $val) { $sql='DELETE FROM '.FILTER_RULE_TABLE .' WHERE filter_id='.db_input($this->getId()) .' AND what='.db_input($what) .' AND how='.db_input($how) .' AND val='.db_input($val); return (db_query($sql) && db_affected_rows()); } function getRule($id) { return $this->getRuleById($id); } function getRuleById($id) { return FilterRule::lookup($id,$this->getId()); } function containsRule($what, $how, $val) { $val = trim($val); if (isset($this->ht['rules'])) { $match = array("w"=>$what, "h"=>$how, "v"=>$val); foreach ($this->ht['rules'] as $rule) { if ($match == $rule) return true; } return false; } else { # Fetch from database return 0 != db_count( "SELECT COUNT(*) FROM ".FILTER_RULE_TABLE ." WHERE filter_id=".db_input($this->id) ." AND what=".db_input($what)." AND how=".db_input($how) ." AND val=".db_input($val) ); } } /** * Simple true/false if the rules defined for this filter match the * incoming email * * $info is an ARRAY, which has valid keys * email - FROM email address of the ticket owner * name - name of ticket owner * subject - subject line of the ticket * body - body content of the message (no attachments, please) * reply-to - reply-to email address * reply-to-name - name of sender to reply-to * headers - array of email headers * emailId - osTicket system email id */ function matches($what) { if(!$what || !is_array($what)) return false; $how = array( # how => array(function, null or === this, null or !== this) 'equal' => array('strcasecmp', 0), 'not_equal' => array('strcasecmp', null, 0), 'contains' => array('stripos', null, false), 'dn_contain'=> array('stripos', false), 'starts' => array('stripos', 0), 'ends' => array('iendsWith', true), 'match' => array('pregMatchB', 1), 'not_match' => array('pregMatchB', null, 0), ); $match = false; # Respect configured filter email-id if ($this->getEmailId() && !strcasecmp($this->getTarget(), 'Email') && $this->getEmailId() != $what['emailId']) return false; foreach ($this->getRules() as $rule) { if (!isset($how[$rule['h']])) continue; list($func, $pos, $neg) = $how[$rule['h']]; $result = call_user_func($func, $what[$rule['w']], $rule['v']); if (($pos === null && $result !== $neg) or ($result === $pos)) { # Match. $match = true; if (!$this->matchAllRules()) break; } else { # No match. Continue? if ($this->matchAllRules()) { $match = false; break; } } } return $match; } /** * If the matches() method returns TRUE, send the initial ticket to this * method to apply the filter actions defined */ function apply(&$ticket, $info=null) { # TODO: Disable alerting # XXX: Does this imply turning it on as well? (via ->sendAlerts()) if ($this->disableAlerts()) $ticket['autorespond']=false; # Set owning department (?) if ($this->getDeptId()) $ticket['deptId']=$this->getDeptId(); # Set ticket priority (?) if ($this->getPriorityId()) $ticket['priorityId']=$this->getPriorityId(); # Set SLA plan (?) if ($this->getSLAId()) $ticket['slaId']=$this->getSLAId(); # Auto-assign to (?) # XXX: Unset the other (of staffId or teamId) (?) if ($this->getStaffId()) $ticket['staffId']=$this->getStaffId(); elseif ($this->getTeamId()) $ticket['teamId']=$this->getTeamId(); # Override name with reply-to information from the TicketFilter # match if ($this->useReplyToEmail() && $info['reply-to']) { $ticket['email'] = $info['reply-to']; if ($info['reply-to-name']) $ticket['name'] = $info['reply-to-name']; } # Use canned response. if ($this->getCannedResponse()) $ticket['cannedResponseId'] = $this->getCannedResponse(); } /* static */ function getSupportedMatches() { foreach (static::$match_types as $k=>&$v) { if (is_callable($v)) $v = $v(); } unset($v); return static::$match_types; } static function addSupportedMatches($group, $callable) { static::$match_types[$group] = $callable; } static function getSupportedMatchFields() { $keys = array(); foreach (static::getSupportedMatches() as $group=>$matches) { foreach ($matches as $key=>$label) { $keys[] = $key; } } return $keys; } /* static */ function getSupportedMatchTypes() { return array( 'equal'=> 'Equal', 'not_equal'=> 'Not Equal', 'contains'=> 'Contains', 'dn_contain'=> 'Does Not Contain', 'starts'=> 'Starts With', 'ends'=> 'Ends With', 'match'=> 'Matches Regex', 'not_match'=> 'Does Not Match Regex', ); } function update($vars,&$errors) { if(!Filter::save($this->getId(),$vars,$errors)) return false; $this->reload(); return true; } function delete() { $id=$this->getId(); $sql='DELETE FROM '.FILTER_TABLE.' WHERE id='.db_input($id).' LIMIT 1'; if(db_query($sql) && ($num=db_affected_rows())) { db_query('DELETE FROM '.FILTER_RULE_TABLE.' WHERE filter_id='.db_input($id)); } return $num; } /** static functions **/ function getTargets() { return array( 'Any' => 'Any', 'Web' => 'Web Forms', 'API' => 'API Calls', 'Email' => 'Emails'); } function create($vars,&$errors) { return Filter::save(0,$vars,$errors); } function getIdByName($name) { $sql='SELECT id FROM '.FILTER_TABLE.' WHERE name='.db_input($name); if(($res=db_query($sql)) && db_num_rows($res)) list($id)=db_fetch_row($res); return $id; } function lookup($id) { return ($id && is_numeric($id) && ($f= new Filter($id)) && $f->getId()==$id)?$f:null; } function validate_rules($vars,&$errors) { return self::save_rules(0,$vars,$errors); } function save_rules($id,$vars,&$errors) { $matches = array_keys(self::getSupportedMatchFields()); $types = array_keys(self::getSupportedMatchTypes()); $rules=array(); for($i=1; $i<=25; $i++) { //Expecting no more than 25 rules... if($vars["rule_w$i"] || $vars["rule_h$i"]) { // Check for REGEX compile errors if (in_array($vars["rule_h$i"], array('match','not_match'))) { $wrapped = "/".$vars["rule_v$i"]."/iu"; if (false === @preg_match($vars["rule_v$i"], ' ') && (false !== @preg_match($wrapped, ' '))) $vars["rule_v$i"] = $wrapped; } if(!$vars["rule_w$i"] || !in_array($vars["rule_w$i"],$matches)) $errors["rule_$i"]='Invalid match selection'; elseif(!$vars["rule_h$i"] || !in_array($vars["rule_h$i"],$types)) $errors["rule_$i"]='Invalid match type selection'; elseif(!$vars["rule_v$i"]) $errors["rule_$i"]='Value required'; elseif($vars["rule_w$i"]=='email' && $vars["rule_h$i"]=='equal' && !Validator::is_email($vars["rule_v$i"])) $errors["rule_$i"]='Valid email required for the match type'; elseif (in_array($vars["rule_h$i"], array('match','not_match')) && (false === @preg_match($vars["rule_v$i"], ' '))) $errors["rule_$i"] = sprintf('Regex compile error: (#%s)', preg_last_error()); else //for everything-else...we assume it's valid. $rules[]=array('what'=>$vars["rule_w$i"], 'how'=>$vars["rule_h$i"],'val'=>$vars["rule_v$i"]); }elseif($vars["rule_v$i"]) { $errors["rule_$i"]='Incomplete selection'; } } if(!$rules && is_array($vars["rules"])) # XXX: Validation bypass $rules = $vars["rules"]; elseif(!$rules && !$errors) $errors['rules']='You must set at least one rule.'; if($errors) return false; if(!$id) return true; //When ID is 0 then assume it was just validation... //Clear existing rules...we're doing mass replace on each save!! db_query('DELETE FROM '.FILTER_RULE_TABLE.' WHERE filter_id='.db_input($id)); $num=0; foreach($rules as $rule) { $rule['filter_id']=$id; if(FilterRule::create($rule, $errors)) $num++; } return $num; } function save($id,$vars,&$errors) { if(!$vars['execorder']) $errors['execorder'] = 'Order required'; elseif(!is_numeric($vars['execorder'])) $errors['execorder'] = 'Must be numeric value'; if(!$vars['name']) $errors['name'] = 'Name required'; elseif(($sid=self::getIdByName($vars['name'])) && $sid!=$id) $errors['name'] = 'Name already in use'; if(!$errors && !self::validate_rules($vars,$errors) && !$errors['rules']) $errors['rules'] = 'Unable to validate rules as entered'; $targets = self::getTargets(); if(!$vars['target']) $errors['target'] = 'Target required'; else if(!is_numeric($vars['target']) && !$targets[$vars['target']]) $errors['target'] = 'Unknown or invalid target'; if($errors) return false; $emailId = 0; if(is_numeric($vars['target'])) { $emailId = $vars['target']; $vars['target'] = 'Email'; } $sql=' updated=NOW() ' .',isactive='.db_input($vars['isactive']) .',target='.db_input($vars['target']) .',name='.db_input($vars['name']) .',execorder='.db_input($vars['execorder']) .',email_id='.db_input($emailId) .',dept_id='.db_input($vars['dept_id']) .',priority_id='.db_input($vars['priority_id']) .',sla_id='.db_input($vars['sla_id']) .',match_all_rules='.db_input($vars['match_all_rules']) .',stop_onmatch='.db_input(isset($vars['stop_onmatch'])?1:0) .',reject_ticket='.db_input(isset($vars['reject_ticket'])?1:0) .',use_replyto_email='.db_input(isset($vars['use_replyto_email'])?1:0) .',disable_autoresponder='.db_input(isset($vars['disable_autoresponder'])?1:0) .',canned_response_id='.db_input($vars['canned_response_id']) .',notes='.db_input(Format::sanitize($vars['notes'])); //Auto assign ID is overloaded... if($vars['assign'] && $vars['assign'][0]=='s') $sql.=',team_id=0,staff_id='.db_input(preg_replace("/[^0-9]/", "",$vars['assign'])); elseif($vars['assign'] && $vars['assign'][0]=='t') $sql.=',staff_id=0,team_id='.db_input(preg_replace("/[^0-9]/", "",$vars['assign'])); else $sql.=',staff_id=0,team_id=0 '; //no auto-assignment! if($id) { $sql='UPDATE '.FILTER_TABLE.' SET '.$sql.' WHERE id='.db_input($id); if(!db_query($sql)) $errors['err']='Unable to update the filter. Internal error occurred'; }else{ $sql='INSERT INTO '.FILTER_TABLE.' SET '.$sql.',created=NOW() '; if(!db_query($sql) || !($id=db_insert_id())) $errors['err']='Unable to add filter. Internal error'; } if($errors || !$id) return false; //Success with update/create...save the rules. We can't recover from any errors at this point. # Don't care about errors stashed in $xerrors $xerrors = array(); self::save_rules($id,$vars,$xerrors); return true; } } class FilterRule { var $id; var $ht; var $filter; function FilterRule($id,$filterId=0) { $this->id=0; $this->load($id,$filterId); } function load($id,$filterId=0) { $sql='SELECT rule.* FROM '.FILTER_RULE_TABLE.' rule ' .' WHERE rule.id='.db_input($id); if($filterId) $sql.=' AND rule.filter_id='.db_input($filterId); if(!($res=db_query($sql)) || !db_num_rows($res)) return false; $this->ht=db_fetch_array($res); $this->id=$this->ht['id']; $this->filter=null; return true; } function reload() { return $this->load($this->getId()); } function getId() { return $this->id; } function isActive() { return ($this->ht['isactive']); } function getHashtable() { return $this->ht; } function getInfo() { return $this->getHashtable(); } function getFilterId() { return $this->ht['filter_id']; } function getFilter() { if(!$this->filter && $this->getFilterId()) $this->filter = Filter::lookup($this->getFilterId()); return $this->filter; } function update($vars,&$errors) { if(!$this->save($this->getId(),$vars,$errors)) return false; $this->reload(); return true; } function delete() { $sql='DELETE FROM '.FILTER_RULE_TABLE.' WHERE id='.db_input($this->getId()).' AND filter_id='.db_input($this->getFilterId()); return (db_query($sql) && db_affected_rows()); } /* static */ function create($vars,&$errors) { return self::save(0,$vars,$errors); } /* static private */ function save($id,$vars,&$errors) { if(!$vars['filter_id']) $errors['err']='Parent filter ID required'; if($errors) return false; $sql=' updated=NOW() '. ',what='.db_input($vars['what']). ',how='.db_input($vars['how']). ',val='.db_input($vars['val']). ',isactive='.db_input(isset($vars['isactive'])?$vars['isactive']:1); if(isset($vars['notes'])) $sql.=',notes='.db_input($vars['notes']); if($id) { $sql='UPDATE '.FILTER_RULE_TABLE.' SET '.$sql.' WHERE id='.db_input($id).' AND filter_id='.db_input($vars['filter_id']); if(db_query($sql)) return true; } else { $sql='INSERT INTO '.FILTER_RULE_TABLE.' SET created=NOW(), filter_id='.db_input($vars['filter_id']).', '.$sql; if(db_query($sql) && ($id=db_insert_id())) return $id; } return false; } /* static */ function lookup($id,$filterId=0) { return ($id && is_numeric($id) && ($r= new FilterRule($id,$filterId)) && $r->getId()==$id)?$r:null; } } /** * Applies rules defined in the admin control panel > Settings tab > "Ticket Filters". Each * filter can have up to 25 rules (*currently). This will attempt to match * the incoming tickets against the defined rules, and, if the email matches, * the ticket will be modified as described in the filter actions. */ class TicketFilter { var $target; var $vars; /** * Construct a list of filters to handle a new ticket * taking into account the source/origin of the ticket. * * $vars is an ARRAY, which has valid keys * *email - email address of user * name - name of user * subject - subject of the ticket * emailId - id of osTicket's system email (for emailed tickets) * --------------- * @see Filter::matches() for a complete list of supported keys */ function TicketFilter($origin, $vars=array()) { //Normalize the target based on ticket's origin. $this->target = self::origin2target($origin); //Extract the vars we care about (fields we filter by!). $this->vars = array('body'=>$vars['message']); $interest = Filter::getSupportedMatchFields(); // emailId is always significant to the filter process $interest[] = 'emailId'; foreach ($vars as $k=>$v) { if (in_array($k, $interest)) $this->vars[$k] = trim($v); } if (isset($vars['recipients']) && $vars['recipients']) { foreach ($vars['recipients'] as $r) { $this->vars['addressee'][] = $r['name']; $this->vars['addressee'][] = $r['email']; } $this->vars['addressee'] = implode(' ', $this->vars['addressee']); } //Init filters. $this->build(); } function build() { //Clear any memoized filters $this->filters = array(); $this->short_list = null; //Query DB for "possibly" matching filters. $res = $this->getAllActive(); if($res) { while (list($id) = db_fetch_row($res)) array_push($this->filters, new Filter($id)); } return $this->filters; } function getTarget() { return $this->target; } /** * Fetches the short list of filters that match the ticket vars received in the * constructor. This function is memoized so subsequent calls will * return immediately. */ function getMatchingFilterList() { if (!isset($this->short_list)) { $this->short_list = array(); foreach ($this->filters as $filter) if ($filter->matches($this->vars)) $this->short_list[] = $filter; } return $this->short_list; } /** * Determine if the filters that match the received vars indicate that * the email should be rejected * * Returns FALSE if the email should be acceptable. If the email should * be rejected, the first filter that matches and has reject ticket set is * returned. */ function shouldReject() { foreach ($this->getMatchingFilterList() as $filter) { # Set reject if this filter indicates that the email should # be blocked; however, don't unset $reject, because if it # was set by another rule that did not set stopOnMatch(), we # should still honor its configuration if ($filter->rejectOnMatch()) return $filter; } return false; } /** * Determine if any filters match the received email, and if so, apply * actions defined in those filters to the ticket-to-be-created. */ function apply(&$ticket) { foreach ($this->getMatchingFilterList() as $filter) { $filter->apply($ticket, $this->vars); if ($filter->stopOnMatch()) break; } } /* static */ function getAllActive() { $sql='SELECT id FROM '.FILTER_TABLE .' WHERE isactive=1 ' .' AND target IN ("Any", '.db_input($this->getTarget()).') '; #Take into account email ID. if($this->vars['emailId']) $sql.=' AND (email_id=0 OR email_id='.db_input($this->vars['emailId']).')'; $sql.=' ORDER BY execorder'; return db_query($sql); } /** * Quick function to determine if the received email-address is * indicated by an active email filter to be banned. Returns the id of * the filter that has the address blacklisted and FALSE if the email is * not blacklisted. * * XXX: If more detailed matching is to be supported, perhaps this * should receive an array like the constructor and * Filter::matches() method. * Peter - Let's keep it as a quick scan for obviously banned emails. */ /* static */ function isBanned($addr) { $sql='SELECT filter.id, what, how, UPPER(val) ' .' FROM '.FILTER_TABLE.' filter' .' INNER JOIN '.FILTER_RULE_TABLE.' rule' .' ON (filter.id=rule.filter_id)' .' WHERE filter.reject_ticket' .' AND filter.match_all_rules=0' .' AND filter.email_id=0' .' AND filter.isactive' .' AND rule.isactive ' .' AND rule.what="email"' .' AND LOCATE(rule.val,'.db_input($addr).')'; if(!($res=db_query($sql)) || !db_num_rows($res)) return false; # XXX: Use MB_xxx function for proper unicode support $addr = strtoupper($addr); $how=array('equal' => array('strcmp', 0), 'contains' => array('strpos', null, false)); while ($row=db_fetch_array($res)) { list($func, $pos, $neg) = $how[$row['how']]; if (!$func) continue; $result = call_user_func($func, $addr, $row['val']); if (($neg === null && $result === $pos) || $result !== $neg) return $row['id']; } return false; } /** * Simple true/false if the headers of the email indicate that the email * is an automatic response. * * Thanks to http://wiki.exim.org/EximAutoReply * X-Auto-Response-Supress is outlined here, * http://msdn.microsoft.com/en-us/library/ee219609(v=exchg.80).aspx */ /* static */ function isAutoReply($headers) { if($headers && !is_array($headers)) $headers = Mail_Parse::splitHeaders($headers); $auto_headers = array( 'Auto-Submitted' => array('AUTO-REPLIED', 'AUTO-GENERATED'), 'Precedence' => array('AUTO_REPLY', 'BULK', 'JUNK', 'LIST'), 'X-Precedence' => array('AUTO_REPLY', 'BULK', 'JUNK', 'LIST'), 'X-Autoreply' => 'YES', 'X-Auto-Response-Suppress' => array('ALL', 'DR', 'RN', 'NRN', 'OOF', 'AutoReply'), 'X-Autoresponse' => '*', 'X-AutoReply-From' => '*', 'X-Autorespond' => '*', 'X-Mail-Autoreply' => '*', 'X-Autogenerated' => 'REPLY', ); foreach ($auto_headers as $header=>$find) { if(!isset($headers[$header])) continue; $value = strtoupper($headers[$header]); # Search text must be found at the beginning of the header # value. This is especially import for something like the # subject line, where something like an autoreponse may # appear somewhere else in the value. if (is_array($find)) { foreach ($find as $f) if (strpos($value, $f) === 0) return true; } elseif ($find === '*') { return true; } elseif (strpos($value, $find) === 0) { return true; } } return false; } static function isBounce($headers) { if($headers && !is_array($headers)) $headers = Mail_Parse::splitHeaders($headers); $bounce_headers = array( 'From' => array('stripos', array('MAILER-DAEMON', '<>'), null, false), 'Subject' => array('stripos', array('DELIVERY FAILURE', 'DELIVERY STATUS', 'UNDELIVERABLE:', 'Undelivered Mail Returned'), 0), 'Return-Path' => array('strcmp', array('<>'), 0), 'Content-Type' => array('stripos', array('report-type=delivery-status'), null, false), 'X-Failed-Recipients' => array('strpos', array('@'), null, false) ); foreach ($bounce_headers as $header => $find) { if(!isset($headers[$header])) continue; @list($func, $searches, $pos, $neg) = $find; if(!($value = $headers[$header]) || !is_array($searches)) continue; foreach ($searches as $f) { $result = call_user_func($func, $value, $f); if (($pos === null && $result !== $neg) or ($result === $pos)) return true; } } return false; } /** * Normalize ticket source to supported filter target * */ function origin2target($origin) { $sources=array('web' => 'Web', 'email' => 'Email', 'phone' => 'Web', 'staff' => 'Web', 'api' => 'API'); return $sources[strtolower($origin)]; } } /** * Function: endsWith * * Returns TRUE if the haystack ends with needle and FALSE otherwise. * Thanks, http://stackoverflow.com/a/834355 */ function iendsWith($haystack, $needle) { $length = mb_strlen($needle); if ($length == 0) { return true; } return (strcasecmp(mb_substr($haystack, -$length), $needle) === 0); } function pregMatchB($subject, $pattern) { return preg_match($pattern, $subject); } ?>