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:
**********************************************************************/
require_once INCLUDE_DIR . 'class.filter_action.php';
/* @trans */ 'User Information' => array(
array('name' => /* @trans */ 'Name',
'email' => /* @trans */ 'Email',
/* @trans */ 'Email Meta-Data' => array(
array('reply-to' => /* @trans */ 'Reply-To Email',
'reply-to-name' => /* @trans */ 'Reply-To Name',
'addressee' => /* @trans */ 'Addressee (To and Cc)',
$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 getStatusId() {
return $this->ht['status_id'];
}
function getCannedResponse() {
return $this->ht['canned_response_id'];
function getHelpTopic() {
return $this->ht['topic_id'];
}
return ($this->ht['stop_onmatch']);
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 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('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),
# Respect configured filter email-id
Peter Rotich
committed
&& !strcasecmp($this->getTarget(), 'Email')
soif
committed
if (!isset($how[$rule['h']])) continue;
$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?
function getActions() {
return FilterAction::objects()->filter(array(
'filter_id'=>$this->getId()
));
}
* If the matches() method returns TRUE, send the initial ticket to this
* method to apply the filter actions defined
*/
function apply(&$ticket, $vars) {
foreach ($this->getActions() as $a) {
$a->setFilter($this);
$a->apply($ticket, $vars);
function getVars() {
return $this->vars;
}
static function getSupportedMatches() {
foreach (static::$match_types as $k=>&$v) {
if (is_callable($v[0]))
$v[0] = $v[0]();
uasort(static::$match_types, function($a, $b) { return $a[1] - $b[1]; });
return array_map(function($a) { return $a[0]; }, static::$match_types);
static function addSupportedMatches($group, $callable, $order=10) {
static::$match_types[$group] = array($callable, $order);
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'),
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 **/
'Any' => __('Any'),
'Web' => __('Web Forms'),
'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());
foreach ($vars['rules'] as $i=>$rule) {
if($rule["w"] || $rule["h"]) {
// Check for REGEX compile errors
if (in_array($rule["h"], array('match','not_match'))) {
$wrapped = "/".$rule["v"]."/iu";
if (false === @preg_match($rule["v"], ' ')
&& (false !== @preg_match($wrapped, ' ')))
if(!$rule["w"] || !in_array($rule["w"],$matches))
$errors["rule_$i"]=__('Invalid match selection');
elseif(!$rule["h"] || !in_array($rule["h"],$types))
$errors["rule_$i"]=__('Invalid match type selection');
$errors["rule_$i"]=__('Value required');
elseif($rule["w"]=='email'
&& $rule["h"]=='equal'
&& !Validator::is_email($rule["v"]))
$errors["rule_$i"]=__('Valid email required for the match type');
elseif (in_array($rule["h"], array('match','not_match'))
&& (false === @preg_match($rule["v"], ' ')))
$errors["rule_$i"] = sprintf(__('Regex compile error: (#%s)'),
preg_last_error());
else //for everything-else...we assume it's valid.
$rules[]=array('what'=>$rule["w"],
'how'=>$rule["h"],'val'=>trim($rule["v"]));
}elseif($rule["v"]) {
$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'] = __('Order required');
$errors['execorder'] = __('Must be numeric value');
$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');
$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)
.',match_all_rules='.db_input($vars['match_all_rules'])
.',stop_onmatch='.db_input(isset($vars['stop_onmatch'])?1:0)
.',notes='.db_input(Format::sanitize($vars['notes']));
$sql='UPDATE '.FILTER_TABLE.' SET '.$sql.' WHERE id='.db_input($id);
$errors['err']=sprintf(__('Unable to update %s.'), __('this ticket filter'))
.' '.__('Internal error occurred');
$sql='INSERT INTO '.FILTER_TABLE.' SET '.$sql.',created=NOW() ';
$errors['err']=sprintf(__('Unable to add %s.'), __('this ticket filter'))
.' '.__('Internal error occurred');
}
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);
self::save_actions($id, $vars, $errors);
function save_actions($id, $vars, &$errors) {
if (!is_array(@$vars['actions']))
return;
foreach ($vars['actions'] as $sort=>$v) {
if (is_array($v)) {
$info = $v['type'];
$sort = $v['sort'] ?: $sort;
$action = 'N';
} else {
$action = $v[0];
$info = substr($v, 1);
}
switch ($action) {
case 'N': # new filter action
$I = FilterAction::create(array(
'type'=>$info,
'filter_id'=>$id,
'sort' => (int) $sort,
$I->setConfiguration($errors, $vars);
$I->save();
break;
case 'I': # exiting filter action
if ($I = FilterAction::lookup($info)) {
$I->setConfiguration($errors, $vars);
$I->sort = (int) $sort;
break;
case 'D': # deleted filter action
if ($I = FilterAction::lookup($info))
$I->delete();
break;
}
}
}
}
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'];
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
$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');
',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']);
}
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))
$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))
/**
* Determine if any filters match the received email, and if so, apply
* actions defined in those filters to the ticket-to-be-created.
*
* Throws:
* RejectedException if the email should not be acceptable. If the email
* should be rejected, the first filter that matches and has reject
* ticket set is returned.
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';
/**
* 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
*/
function isAutoReply($headers) {
if($headers && !is_array($headers))
$headers = Mail_Parse::splitHeaders($headers);
'Auto-Submitted' => array('AUTO-REPLIED', 'AUTO-GENERATED'),
'Precedence' => array('AUTO_REPLY', 'BULK', 'JUNK', 'LIST'),
'X-Precedence' => array('AUTO_REPLY', 'BULK', 'JUNK', 'LIST'),
'X-Auto-Response-Suppress' => array('ALL', 'DR', 'RN', 'NRN', 'OOF', 'AutoReply'),
'X-Autoresponse' => '*',
'X-AutoReply-From' => '*',
'X-Autorespond' => '*',
'X-Mail-Autoreply' => '*',
'X-Autogenerated' => 'REPLY',
'X-AMAZON-MAIL-RELAY-TYPE' => 'NOTIFICATION',
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;
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;
/**
* 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)];
}
class RejectedException extends Exception {
var $filter;
function __construct(Filter $filter, $vars) {
parent::__construct('Ticket rejected by a filter');
$this->filter = $filter;
}
function getRejectingFilter() {
return $this->filter;
}
function get($what) {
return $this->vars[$what];
}
class FilterDataChanged extends Exception {
var $data;
function __construct($data) {
parent::__construct('Ticket filter data changed');
$this->data = $data;
}
function getData() {
return $this->data;
}
}
/**
* 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);