<?php
/* vim: set expandtab tabstop=4 shiftwidth=4 encoding=utf-8: */
// +----------------------------------------------------------------------+
// | Eventum - Issue Tracking System                                      |
// +----------------------------------------------------------------------+
// | Copyright (c) 2003 - 2008 MySQL AB                                   |
// | Copyright (c) 2008 - 2009 Sun Microsystem Inc.                       |
// |                                                                      |
// | This program is free software; you can redistribute it and/or modify |
// | it under the terms of the GNU General Public License as published by |
// | the Free Software Foundation; either version 2 of the License, or    |
// | (at your option) any later version.                                  |
// |                                                                      |
// | This program is distributed in the hope that it will be useful,      |
// | but WITHOUT ANY WARRANTY; without even the implied warranty of       |
// | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the        |
// | GNU General Public License for more details.                         |
// |                                                                      |
// | You should have received a copy of the GNU General Public License    |
// | along with this program; if not, write to:                           |
// |                                                                      |
// | Free Software Foundation, Inc.                                       |
// | 59 Temple Place - Suite 330                                          |
// | Boston, MA 02111-1307, USA.                                          |
// +----------------------------------------------------------------------+
// | Authors: Bryan Alsdorf <bryan@mysql.com>                             |
// +----------------------------------------------------------------------+
//

require_once(APP_INC_PATH . "class.misc.php");
require_once(APP_INC_PATH . "class.mail.php");
require_once(APP_INC_PATH . "class.support.php");
require_once(APP_INC_PATH . "class.issue.php");
require_once(APP_INC_PATH . "class.mime_helper.php");
require_once(APP_INC_PATH . "class.date.php");
require_once(APP_INC_PATH . "class.setup.php");
require_once(APP_INC_PATH . "class.notification.php");
require_once(APP_INC_PATH . "class.user.php");
require_once(APP_INC_PATH . "class.note.php");
require_once(APP_INC_PATH . "class.project.php");
require_once(APP_INC_PATH . "class.status.php");
require_once(APP_INC_PATH . "class.history.php");

/**
 * Class to handle all routing functionality
 *
 * @author  Bryan Alsdorf <bryan@mysql.com>
 * @version 1.0
 */
class Routing
{
    /**
     * Routes an email to the correct issue.
     *
     * @param   string $full_message The full email message, including headers
     * @return  mixed   true or array(ERROR_CODE, ERROR_STRING) in case of failure
     */
    function route_emails($full_message)
    {
        // need some validation here
        if (empty($full_message)) {
            return array(66, ev_gettext("Error: The email message was empty") . ".\n");
        }

        // save the full message for logging purposes
        Support::saveRoutedEmail($full_message);

        // check if the email routing interface is even supposed to be enabled
        $setup = Setup::load();
        if ($setup['email_routing']['status'] != 'enabled') {
            return array(78, ev_gettext("Error: The email routing interface is disabled.") . "\n");
        }
        if (empty($setup['email_routing']['address_prefix'])) {
            return array(78, ev_gettext("Error: Please configure the email address prefix.") . "\n");
        }
        if (empty($setup['email_routing']['address_host'])) {
            return array(78, ev_gettext("Error: Please configure the email address domain.") . "\n");
        }

        // associate routed emails to the internal system account
        $sys_account = User::getNameEmail(APP_SYSTEM_USER_ID);
        if (empty($sys_account['usr_email'])) {
            return array(78, ev_gettext("Error: The associated user for the email routing interface needs to be set.") . "\n");
        }
        unset($sys_account);

        // join the Content-Type line (for easier parsing?)
        if (preg_match('/^boundary=/m', $full_message)) {
            $pattern = "#(Content-Type: multipart/.+); ?\r?\n(boundary=.*)$#im";
            $replacement = '$1; $2';
            $full_message = preg_replace($pattern, $replacement, $full_message);
        }

        // remove the reply-to: header
        if (preg_match('/^reply-to:.*/im', $full_message)) {
            $full_message = preg_replace("/^(reply-to:).*\n/im", '', $full_message, 1);
        }

        Auth::createFakeCookie(APP_SYSTEM_USER_ID);

        $structure = Mime_Helper::decode($full_message, true, true);

        // find which issue ID this email refers to
        if (isset($structure->headers['to'])) {
            $issue_id = Routing::getMatchingIssueIDs($structure->headers['to'], 'email');
        }
        // we need to try the Cc header as well
        if (empty($issue_id) and isset($structure->headers['cc'])) {
            $issue_id = Routing::getMatchingIssueIDs($structure->headers['cc'], 'email');
        }

        if (empty($issue_id)) {
            return array(65, ev_gettext("Error: The routed email had no associated Eventum issue ID or had an invalid recipient address.") . "\n");
        }

        $issue_prj_id = Issue::getProjectID($issue_id);
        if (empty($issue_prj_id)) {
            return array(65, ev_gettext("Error: The routed email had no associated Eventum issue ID or had an invalid recipient address.") . "\n");
        }

        $email_account_id = Email_Account::getEmailAccount($issue_prj_id);
        if (empty($email_account_id)) {
            return array(78, ev_gettext("Error: Please provide the email account ID.") . "\n");
        }

        $body = $structure->body;

        // hack for clients that set more then one from header
        if (is_array($structure->headers['from'])) {
            $structure->headers['from'] = $structure->headers['from'][0];
        }

        // associate the email to the issue
        $parts = array();
        Mime_Helper::parse_output($structure, $parts);

        // get the sender's email address
        $sender_email = strtolower(Mail_API::getEmailAddress($structure->headers['from']));

        // strip out the warning message sent to staff users
        if (($setup['email_routing']['status'] == 'enabled') &&
                ($setup['email_routing']['warning']['status'] == 'enabled')) {
            $full_message = Mail_API::stripWarningMessage($full_message);
            $body = Mail_API::stripWarningMessage($body);
        }

        $prj_id = Issue::getProjectID($issue_id);
        Auth::createFakeCookie(APP_SYSTEM_USER_ID, $prj_id);

        if (Mime_Helper::hasAttachments($structure)) {
            $has_attachments = 1;
        } else {
            $has_attachments = 0;
        }

        // remove certain CC addresses
        if ((!empty($structure->headers['cc'])) && (@$setup['smtp']['save_outgoing_email'] == 'yes')) {
            $ccs = explode(",", @$structure->headers['cc']);
            for ($i = 0; $i < count($ccs); $i++) {
                if (Mail_API::getEmailAddress($ccs[$i]) == $setup['smtp']['save_address']) {
                    unset($ccs[$i]);
                }
            }
            @$structure->headers['cc'] = join(', ', $ccs);
        }

        // Remove excess Re's
        @$structure->headers['subject'] = Mail_API::removeExcessRe(@$structure->headers['subject'], true);

        $t = array(
            'issue_id'       => $issue_id,
            'ema_id'         => $email_account_id,
            'message_id'     => @$structure->headers['message-id'],
            'date'           => Date_API::getCurrentDateGMT(),
            'from'           => @$structure->headers['from'],
            'to'             => @$structure->headers['to'],
            'cc'             => @$structure->headers['cc'],
            'subject'        => @$structure->headers['subject'],
            'body'           => @$body,
            'full_email'     => @$full_message,
            'has_attachment' => $has_attachments,
            'headers'        => @$structure->headers
        );
        // automatically associate this incoming email with a customer
        if (Customer::hasCustomerIntegration($prj_id)) {
            if (!empty($structure->headers['from'])) {
                list($customer_id,) = Customer::getCustomerIDByEmails($prj_id, array($sender_email));
                if (!empty($customer_id)) {
                    $t['customer_id'] = $customer_id;
                }
            }
        }
        if (empty($t['customer_id'])) {
            $t['customer_id'] = "NULL";
        }

        if (Support::blockEmailIfNeeded($t)) {
            return true;
        }

        // re-write Threading headers if needed
        list($t['full_email'], $t['headers']) = Mail_API::rewriteThreadingHeaders($t['issue_id'], $t['full_email'], $t['headers'], "email");
        $res = Support::insertEmail($t, $structure, $sup_id);
        if ($res != -1) {
            Support::extractAttachments($issue_id, $structure);

            // notifications about new emails are always external
            $internal_only = false;
            $assignee_only = false;
            // special case when emails are bounced back, so we don't want a notification to customers about those
            if (Notification::isBounceMessage($sender_email)) {
                // broadcast this email only to the assignees for this issue
                $internal_only = true;
                $assignee_only = true;
            }
            Notification::notifyNewEmail(Auth::getUserID(), $issue_id, $t, $internal_only, $assignee_only, '', $sup_id);
            // try to get usr_id of sender, if not, use system account
            $usr_id = User::getUserIDByEmail(Mail_API::getEmailAddress($structure->headers['from']));
            if (!$usr_id) {
                $usr_id = APP_SYSTEM_USER_ID;
            }
            // mark this issue as updated
            if ((!empty($t['customer_id'])) && ($t['customer_id'] != 'NULL')) {
                Issue::markAsUpdated($issue_id, 'customer action');
            } else {
                if ((!empty($usr_id)) && (User::getRoleByUser($usr_id, $prj_id) > User::getRoleID('Customer'))) {
                    Issue::markAsUpdated($issue_id, 'staff response');
                } else {
                    Issue::markAsUpdated($issue_id, 'user response');
                }
            }
            // log routed email
            History::add($issue_id, $usr_id, History::getTypeID('email_routed'), ev_gettext('Email routed from %1$s', $structure->headers['from']));
        }

        return true;
    }


    /**
     * Routes a note to the correct issue
     *
     * @param   string $full_message The full note
     * @return  mixed   true or array(ERROR_CODE, ERROR_STRING) in case of failure
     */
    function route_notes($full_message)
    {
        // save the full message for logging purposes
        Note::saveRoutedNote($full_message);

        // join the Content-Type line (for easier parsing?)
        if (preg_match('/^boundary=/m', $full_message)) {
            $pattern = "#(Content-Type: multipart/.+); ?\r?\n(boundary=.*)$#im";
            $replacement = '$1; $2';
            $full_message = preg_replace($pattern, $replacement, $full_message);
        }

        list($headers,) = Mime_Helper::splitHeaderBody($full_message);

        // need some validation here
        if (empty($full_message)) {
            return array(66, ev_gettext("Error: The email message was empty.") . "\n");
        }

        // remove the reply-to: header
        if (preg_match('/^reply-to:.*/im', $full_message)) {
            $full_message = preg_replace("/^(reply-to:).*\n/im", '', $full_message, 1);
        }

        // check if the email routing interface is even supposed to be enabled
        $setup = Setup::load();
        if (@$setup['note_routing']['status'] != 'enabled') {
            return array(78, ev_gettext("Error: The internal note routing interface is disabled.") . "\n");
        }
        if (empty($setup['note_routing']['address_prefix'])) {
            return array(78, ev_gettext("Error: Please configure the email address prefix.") . "\n");
        }
        if (empty($setup['note_routing']['address_host'])) {
            return array(78, ev_gettext("Error: Please configure the email address domain.") . "\n");
        }
        $structure = Mime_Helper::decode($full_message, true, true);

        // find which issue ID this email refers to
        if (isset($structure->headers['to'])) {
            $issue_id = Routing::getMatchingIssueIDs($structure->headers['to'], 'note');
        }
        // validation is always a good idea
        if (empty($issue_id) and isset($structure->headers['cc'])) {
            // we need to try the Cc header as well
            $issue_id = Routing::getMatchingIssueIDs($structure->headers['cc'], 'note');
        }

        if (empty($issue_id)) {
            return array(65, ev_gettext("Error: The routed note had no associated Eventum issue ID or had an invalid recipient address.") . "\n");
        }

        $prj_id = Issue::getProjectID($issue_id);
        // check if the sender is allowed in this issue' project and if it is an internal user
        $sender_usr_id = User::getUserIDByEmail(strtolower(Mail_API::getEmailAddress($structure->headers['from'])), true);
        if ((empty($sender_usr_id)) || (User::getRoleByUser($sender_usr_id, $prj_id) < User::getRoleID('Standard User'))) {
            return array(77, ev_gettext("Error: The sender of this email is not allowed in the project associated with issue #$issue_id.") . "\n");
        }

        Auth::createFakeCookie($sender_usr_id, $prj_id);

        // parse the Cc: list, if any, and add these internal users to the issue notification list
        $addresses = array();
        $to_addresses = Mail_API::getEmailAddresses(@$structure->headers['to']);
        if (count($to_addresses)) {
            $addresses = $to_addresses;
        }
        $cc_addresses = Mail_API::getEmailAddresses(@$structure->headers['cc']);
        if (count($cc_addresses)) {
            $addresses = array_merge($addresses, $cc_addresses);
        }
        $cc_users = array();
        foreach ($addresses as $email) {
            $cc_usr_id = User::getUserIDByEmail(strtolower($email), true);
            if ((!empty($cc_usr_id)) && (User::getRoleByUser($cc_usr_id, $prj_id) >= User::getRoleID("Standard User"))) {
                $cc_users[] = $cc_usr_id;
            }
        }

        $body = $structure->body;
        $reference_msg_id = Mail_API::getReferenceMessageID($headers);
        if (!empty($reference_msg_id)) {
            $parent_id = Note::getIDByMessageID($reference_msg_id);
        } else {
            $parent_id = false;
        }

        // insert the new note and send notification about it
        $_POST = array(
            'title'                => @$structure->headers['subject'],
            'note'                 => $body,
            'note_cc'              => $cc_users,
            'add_extra_recipients' => 'yes',
            'message_id'           => @$structure->headers['message-id'],
            'parent_id'            => $parent_id,
        );

        // add the full email to the note if there are any attachments
        // this is needed because the front end code will display attachment links
        if (Mime_Helper::hasAttachments($structure)) {
            $_POST['full_message'] = $full_message;
        }
        $res = Note::insert(Auth::getUserID(), $issue_id, false, false);
        // need to handle attachments coming from notes as well
        if ($res != -1) {
            Support::extractAttachments($issue_id, $structure, true, $res);
        }
        // FIXME! $res == -2 is not handled
        History::add($issue_id, Auth::getUserID(), History::getTypeID('note_routed'), ev_gettext('Note routed from %1$s', $structure->headers['from']));

        return true;
    }


    /**
     * Routes a draft to the correct issue.
     *
     * @param   string $full_message The complete draft.
     * @return  mixed   true or array(ERROR_CODE, ERROR_STRING) in case of failure
     */
    function route_drafts($full_message)
    {
        // save the full message for logging purposes
        Draft::saveRoutedMessage($full_message);

        if (preg_match("/^(boundary=).*/m", $full_message)) {
            $pattern = "/(Content-Type: multipart\/)(.+); ?\r?\n(boundary=)(.*)$/im";
            $replacement = '$1$2; $3$4';
            $full_message = preg_replace($pattern, $replacement, $full_message);
        }

        // need some validation here
        if (empty($full_message)) {
            return array(66, ev_gettext("Error: The email message was empty.") . "\n");
        }

        // remove the reply-to: header
        if (preg_match("/^(reply-to:).*/im", $full_message)) {
            $full_message = preg_replace("/^(reply-to:).*\n/im", '', $full_message, 1);
        }

        // check if the draft interface is even supposed to be enabled
        $setup = Setup::load();
        if (@$setup['draft_routing']['status'] != 'enabled') {
            return array(78, ev_gettext("Error: The email draft interface is disabled.") . "\n");
        }
        if (empty($setup['draft_routing']['address_prefix'])) {
            return array(78, ev_gettext("Error: Please configure the email address prefix.") . "\n");
        }
        if (empty($setup['draft_routing']['address_host'])) {
            return array(78, ev_gettext("Error: Please configure the email address domain.") . "\n");
        }

        $structure = Mime_Helper::decode($full_message, true, false);

        // find which issue ID this email refers to
        if (isset($structure->headers['to'])) {
            $issue_id = Routing::getMatchingIssueIDs($structure->headers['to'], 'draft');
        }
        // validation is always a good idea
        if (empty($issue_id) and isset($structure->headers['cc'])) {
            // we need to try the Cc header as well
            $issue_id = Routing::getMatchingIssueIDs($structure->headers['cc'], 'draft');
        }

        if (empty($issue_id)) {
            return array(65, ev_gettext("Error: The routed email had no associated Eventum issue ID or had an invalid recipient address.") . "\n");
        }

        $prj_id = Issue::getProjectID($issue_id);
        // check if the sender is allowed in this issue' project and if it is an internal user
        $sender_usr_id = User::getUserIDByEmail(strtolower(Mail_API::getEmailAddress($structure->headers['from'])), true);
        if (!empty($sender_usr_id)) {
            $sender_role = User::getRoleByUser($sender_usr_id, $prj_id);
            if ($sender_role < User::getRoleID('Standard User')) {
                return array(77, ev_gettext("Error: The sender of this email is not allowed in the project associated with issue #$issue_id.") . "\n");
            }
        }

        Auth::createFakeCookie(User::getUserIDByEmail($sender_email), $prj_id);

        $body = $structure->body;

        Draft::saveEmail($issue_id, @$structure->headers['to'], @$structure->headers['cc'], @$structure->headers['subject'], $body, false, false, false);
        // XXX: need to handle attachments coming from drafts as well?
        History::add($issue_id, Auth::getUserID(), History::getTypeID('draft_routed'), ev_gettext("Draft routed from") . " " . $structure->headers['from']);
        return true;
    }

    /**
     * Check for $adresses for matches
     *
     * @param   mixed   $addresses to check
     * @param   string  Type of address match to find (email, note, draft)
     * @return  mixed   $issue_id in case of match otherwise false
     */
    function getMatchingIssueIDs($addresses, $type)
    {
        $setup = Setup::load();
        $settings = $setup["${type}_routing"];
        if (!is_array($settings)) {
            return false;
        }

        if (empty($settings['address_prefix'])) {
            return false;
        }
        // escape plus signs so 'issue+1@example.com' becomes a valid routing address
        $prefix = quotemeta($settings['address_prefix']);

        if (empty($settings['address_host'])) {
            return false;
        }
        $mail_domain = quotemeta($settings['address_host']);

        // it is not checked for type when host alias is asked. this leaves
        // room foradding host_alias for other than email routing.
        if (isset($settings['host_alias'])) {
            // TODO: can't quotemeta() host alias as it can contain multiple hosts separated with pipe
            $mail_domain = '(?:' . $mail_domain . '|' . $settings['host_alias'] . ')';
        }

        // if there are multiple CC or To headers Mail_Mime creates array.
        // handle both cases (strings and arrays).
        if (!is_array($addresses)) {
            $addresses = array($addresses);
        }

        // everything safely escaped and checked, try matching address
        foreach ($addresses as $address) {
            if (preg_match("/$prefix(\d*)@$mail_domain/i", $address, $matches)) {
                return $matches[1];
            }
        }

        return false;
    }
}
