Newer
Older
<?php
/*********************************************************************
class.filter.php
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(
'Basic Fields' => array(
'name' => 'Name',
'email' => 'Email',
'subject' => 'Subject',
'body' => 'Body/Text',
'reply-to' => 'Reply-To Email',
'reply-to-name' => 'Reply-To Name',
),
);
$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;
return true;
}
function reload() {
return $this->load($this->getId());
}
function getTarget() {
return $this->ht['target'];
}
function getName() {
function getEmailId() {
return $this->ht['email_id'];
}
return ($this->ht['isactive']);
}
function isSystemBanlist() {
return !strcasecmp($this->getName(),'SYSTEM BAN LIST');
}
function getCannedResponse() {
return $this->ht['canned_response_id'];
return ($this->ht['stop_on_match']);
}
return ($this->ht['match_all_rules']);
}
function rejectOnMatch() {
return ($this->ht['reject_ticket']);
return ($this->ht['use_replyto_email']);
}
return ($this->ht['disable_autoresponder']);
}
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
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()) {
$rule= array_merge($extra,array('what'=>$what, 'how'=>$how, 'val'=>$val));
return FilterRule::create($rule,$errors);
}
function removeRule($what, $how, $val) {
.' 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) {
$match = array("w"=>$what, "h"=>$how, "v"=>$val);
if ($match == $rule)
return true;
} else {
# Fetch from database
return 0 != db_count(
." 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
$how = array(
# how => array(function, null or === this, null or !== this)
'equal' => array('strcmp', 0),
'not_equal' => array('strcmp', null, 0),
'contains' => array('strpos', null, false),
'dn_contain'=> array('strpos', false),
'starts' => array('strpos', 0),
'ends' => array('endsWith', true)
# Respect configured filter email-id
Peter Rotich
committed
&& !strcasecmp($this->getTarget(), 'Email')
soif
committed
if (!isset($how[$rule['h']])) continue;
list($func, $pos, $neg) = $how[$rule['h']];
# TODO: convert $what and $rule['v'] to mb_strtoupper and do
# case-sensitive, binary-safe comparisons. Would be really
# nice to do $rule['v'] on the database side for
# performance -- but ::getFlatRules() is a blocker
$result = call_user_func($func, strtoupper($what[$rule['w']]),
strtoupper($rule['v']));
if (($pos === null && $result !== $neg) or ($result === $pos)) {
# Match.
$match = true;
if (!$this->matchAllRules()) break;
} else {
# No match. Continue?
* 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
if ($this->useReplyToEmail() && $info['reply-to']) {
$ticket['email'] = $info['reply-to'];
$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'
if(!Filter::save($this->getId(),$vars,$errors))
return false;
$this->reload();
$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 **/
'API' => 'API Calls',
'Email' => 'Emails');
}
function create($vars,&$errors) {
return Filter::save(0,$vars,$errors);
}
$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;
}
return ($id && is_numeric($id) && ($f= new Filter($id)) && $f->getId()==$id)?$f:null;
}
return self::save_rules(0,$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"]) {
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';
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"]);
$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++;
}
$errors['execorder'] = 'Must be numeric value';
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';
$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.
$xerrors = array();
self::save_rules($id,$vars,$xerrors);
return true;
}
}
class FilterRule {
var $id;
var $ht;
var $filter;
$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'];
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
$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;
}
$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;
',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);
}
function build() {
//Clear any memoized filters
$this->short_list = null;
//Query DB for "possibly" matching filters.
if($res) {
while (list($id) = db_fetch_row($res))
array_push($this->filters, new Filter($id));
}
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))
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;
/**
* 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);
$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';
/**
* 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.
*/
$sql='SELECT filter.id, what, how, UPPER(val) '
.' FROM '.FILTER_TABLE.' filter'
.' INNER JOIN '.FILTER_RULE_TABLE.' rule'
.' 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 isAutoResponse($headers) {
if($headers && !is_array($headers))
$headers = Mail_Parse::splitHeaders($headers);
$auto_headers = array(
'Auto-Submitted' => 'AUTO-REPLIED',
'Precedence' => array('AUTO_REPLY', 'BULK', 'JUNK', 'LIST'),
'Subject' => array('OUT OF OFFICE', 'AUTO-REPLY:', 'AUTORESPONSE'),
'X-Autoreply' => 'YES',
'X-Auto-Response-Suppress' => array('ALL', 'DR', 'RN', 'NRN', 'OOF', 'AutoReply'),
'X-Autoresponse' => '',
'X-Auto-Reply-From' => ''
);
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 (strpos($value, $find) === 0) {
return true;
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
# Bounces also counts as auto-responses.
if(self::isAutoBounce($headers))
return true;
return false;
}
function isAutoBounce($headers) {
if($headers && !is_array($headers))
$headers = Mail_Parse::splitHeaders($headers);
$bounce_headers = array(
'From' => array('<MAILER-DAEMON@MAILER-DAEMON>', 'MAILER-DAEMON', '<>'),
'Subject' => array('DELIVERY FAILURE', 'DELIVERY STATUS', 'UNDELIVERABLE:'),
);
foreach ($bounce_headers as $header => $find) {
if(!isset($headers[$header])) continue;
$value = strtoupper($headers[$header]);
if (is_array($find)) {
foreach ($find as $f)
if (strpos($value, $f) === 0)
return true;
} elseif (strpos($value, $find) === 0) {
return true;
}
}
/**
* 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 endsWith($haystack, $needle)
{
$length = strlen($needle);
if ($length == 0) {
return true;
}
return (substr($haystack, -$length) === $needle);
}