<?PHP
#
#   FILE:  Mailer.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2011-2013 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu/cwis
#

/**
* Plugin for providing a common mechanism for sending emails to users
* using templates.
*/
class Mailer extends Plugin {

    # ---- STANDARD PLUGIN INTERFACE -----------------------------------------

    /**
    * Set the plugin attributes.  At minimum this method MUST set $this->Name
    * and $this->Version.  This is called when the plugin is initially loaded.
    */
    function Register()
    {
        $this->Name = "Mailer";
        $this->Version = "1.0.7";
        $this->Description = "Generates and emails messages to users based"
                ." on templates.";
        $this->Author = "Internet Scout";
        $this->Url = "http://scout.wisc.edu/cwis/";
        $this->Email = "scout@scout.wisc.edu";
        $this->Requires = array(
                "CWISCore" => "2.2.4");
        $this->EnabledByDefault = TRUE;

        $this->CfgSetup["BaseUrl"] = array(
                "Type" => "Text",
                "Label" => "Base URL",
                "Help" => "This value overrides any automatically-determined"
                        ." value for the X-BASEURL-X template keyword.",
                );

        $this->CfgSetup["EmailTaskPriority"] = array(
            "Type" => "Option",
            "Label" => "E-mail Task Priority",
            "Help" => "Priority of the e-mail sending tasks when using the task queue.",
            "AllowMultiple" => FALSE,
            "Default" => ApplicationFramework::PRIORITY_BACKGROUND,
            "Options" => array(
                ApplicationFramework::PRIORITY_BACKGROUND => "Background",
                ApplicationFramework::PRIORITY_LOW => "Low",
                ApplicationFramework::PRIORITY_MEDIUM => "Medium",
                ApplicationFramework::PRIORITY_HIGH => "High"));
    }

    /**
    * Perform any work needed when the plugin is first installed (for example,
    * creating database tables).
    * @return NULL if installation succeeded, otherwise a string containing
    *       an error message indicating why installation failed.
    */
    function Install()
    {
        $this->ConfigSetting("Templates", array());
    }

    /**
    * Upgrade from a previous version.
    * @param $PreviousVersion Previous version number as a string.
    */
    function Upgrade($PreviousVersion)
    {
        # upgrade to 1.0.1
        if (version_compare($PreviousVersion, "1.0.1", "<"))
        {
            # get the current list of templates
            $Templates = $this->ConfigSetting("Templates");

            # add the "CollapseBodyMargins" setting
            foreach ($Templates as $Id => $Template)
            {
                $Templates[$Id]["CollapseBodyMargins"] = FALSE;
            }

            # set the updated templates
            $this->ConfigSetting("Templates", $Templates);
        }

        # upgrade to 1.0.2
        if (version_compare($PreviousVersion, "1.0.2", "<"))
        {
            # get the current list of templates
            $Templates = $this->ConfigSetting("Templates");

            # add the plain text fields
            foreach ($Templates as $Id => $Template)
            {
                $Templates[$Id]["PlainTextBody"] = "";
                $Templates[$Id]["PlainTextItemBody"] = "";
            }

            # set the updated templates
            $this->ConfigSetting("Templates", $Templates);
        }

        # upgrade to 1.0.6
        if (version_compare($PreviousVersion, "1.0.6", "<"))
        {
            $this->ConfigSetting(
                "EmailTaskPriority",
                ApplicationFramework::PRIORITY_BACKGROUND);
        }
    }

    /**
    * Hook the events into the application framework.
    * @return Returns an array of events to be hooked into the application
    *      framework.
    */
    function HookEvents()
    {
        return array(
                "EVENT_COLLECTION_ADMINISTRATION_MENU" => "AddCollectionAdminMenuItems",
                );
    }


    # ---- HOOKED METHODS ----------------------------------------------------

    /**
    * Add entries to the Collection Administration menu.
    * @return array List of entries to add, with the label as the value and
    *       the page to link to as the index.
    */
    function AddCollectionAdminMenuItems()
    {
        return array(
                "EditMessageTemplates" => "Edit Email Templates",
                );
    }


    # ---- CALLABLE METHODS --------------------------------------------------

    /**
    * Retrieve list of currently available templates, with template IDs for
    * the indexes and template names for the values.
    * @return Array containing template names.
    */
    function GetTemplateList()
    {
        $Templates = $this->ConfigSetting("Templates");
        $TemplateList = array();
        foreach ($Templates as $Id => $Template)
        {
            $TemplateList[$Id] = $Template["Name"];
        }
        return $TemplateList;
    }

    /**
    * Send email to specified recipients using specified template.
    * @param TemplateId ID of template to use in generating emails.
    * @param Users User object or ID or array of User objects or IDs
    *       as recipients.
    * @param Resources Resource or Resource ID or array of Resources or
    *       array of Resource IDs to be referred to within email messages.
    *       (OPTIONAL, defaults to NULL)
    * @param ExtraValues Array of additional values to swap into template,
    *       with value keywords (without the "X-" and "-X") for the index
    *       and values for the values.  This parameter can be used to
    *       override the builtin keywords.  (OPTIONAL)
    * @return Number of email messages sent.
    */
    function SendEmail($TemplateId, $Users, $Resources = NULL, $ExtraValues = NULL)
    {
        # initialize count of emails sent
        $MessagesSent = 0;

        # convert incoming parameters to arrays if necessary
        if (!is_array($Users)) {  $Users = array($Users);  }
        if ($Resources && !is_array($Resources))
                {  $Resources = array($Resources);  }

        # load resource objects if necessary
        if (count($Resources) && !is_object(reset($Resources)))
        {
            foreach ($Resources as $Id)
            {
                $NewResources[$Id] = new Resource($Id);
            }
            $Resources = $NewResources;
        }

        # retrieve appropriate template
        $Templates = $this->ConfigSetting("Templates");
        $Template = $Templates[$TemplateId];

        # set up parameters for keyword replacement callback
        $this->KRItemBody = $Template["ItemBody"];
        $this->KRResources = $Resources;
        $this->KRExtraValues = $ExtraValues;

        # for each user
        foreach ($Users as $User)
        {
            # set up per-user parameters for keyword replacement callback
            if (!is_object($User))
            {
                if (!isset($DB)) {  $DB = new Database();  }
                $User = new CWUser($User);
                if ($User->Status() != U_OKAY) {  continue;  }
            }
            $this->KRUser = $User;

            # skip if the user is disabled
            if ($User->HasPriv(PRIV_USERDISABLED)) continue;

            # get the recipient address and message subject
            $Address = trim($User->Get("EMail"));
            $Subject = trim($this->ReplaceKeywords($Template["Subject"]));

            # skip if there is no recipient address or subject
            if (!strlen($Address) || !strlen($Subject)) continue;

            # create and set up the message to send
            $Msg = new Email();
            $Msg->CharSet($GLOBALS["AF"]->HtmlCharset());
            $Msg->From($this->ReplaceKeywords($Template["From"]));
            $Msg->To($Address);
            $Msg->Subject($Subject);

            # set up headers for message
            $Headers = array();
            $Headers[] = "Auto-Submitted: auto-generated";
            $Headers[] = "Precedence: list";
            if (strlen($Template["Headers"]))
            {
                $ExtraHeaders = $this->SplitByLineEnding($Template["Headers"]);
                foreach ($ExtraHeaders as $Line)
                {
                    if (strlen(trim($Line)))
                    {
                        $Headers[] = $this->ReplaceKeywords($Line);
                    }
                }
            }

            # add the headers to the message
            $Msg->AddHeaders($Headers);

            # start with the body from the template and replace keywords
            $Body = $this->ReplaceKeywordsInBody($Template["Body"]);

            # wrap HTML where necessary to keep it below 998 characters
            $Body = Email::WrapHtmlAsNecessary($Body);

            # construct the style attribute necessary for collapsing the body
            # margins, if instructed to do so
            $MarginsStyle = $Template["CollapseBodyMargins"]
                ? ' style="margin:0;padding:0;"' : "";

            # wrap the message in boilerplate HTML
            $Body = '<!DOCTYPE html>
                     <html lang="en"'.$MarginsStyle.'>
                       <head><meta charset="'.$GLOBALS["AF"]->HtmlCharset().'" /></head>
                       <body'.$MarginsStyle.'>'.$Body.'</body>
                     </html>';

            # add the body to the message
            $Msg->Body($Body);

            # plain text body and item body are set
            if (strlen(trim($Template["PlainTextBody"])) > 0)
            {
                $this->KRItemBody = $Template["PlainTextItemBody"];

                # start with the body from the template and replace keywords
                $PlainTextBody = $this->ReplaceKeywordsInBody(
                    $Template["PlainTextBody"],
                    FALSE);

                # wrap body where necessary to keep it below 998 characters
                $PlainTextBody = wordwrap($PlainTextBody, 998, TRUE);

                # add the alternate body to the message
                $Msg->AlternateBody($PlainTextBody);
            }

            # an HTML e-mail only
            else
            {
                # additional headers are needed
                $Msg->AddHeaders(array(
                    "MIME-Version: 1.0",
                    "Content-Type: text/html; charset=".$GLOBALS["AF"]->HtmlCharset()
                ));
            }

            # send message to the user
            $Msg->Send();
            $MessagesSent++;
        }

        # report number of emails sent back to caller
        return $MessagesSent;
    }

    /**
    * Send email to specified recipients using specified template but use
    * background tasks instead of trying to send all to all of the
    * recipients at once.
    * @param int $TemplateId ID of template to use in generating emails.
    * @param mixded $Users User object or ID or array of User objects or IDs
    *       as recipients.
    * @param mixed $Resources Resource or Resource ID or array of Resources or
    *       array of Resource IDs to be referred to within email messages.
    *       (OPTIONAL, defaults to NULL)
    * @param array ExtraValues Array of additional values to swap into template,
    *       with value keywords (without the "X-" and "-X") for the index
    *       and values for the values.  This parameter can be used to
    *       override the builtin keywords.  (OPTIONAL)
    * @return Number of email messages to be sent.
    */
    public function SendEmailUsingTasks(
        $TemplateId,
        $Users,
        $Resources = NULL,
        $ExtraValues = NULL)
    {
        # retrieve appropriate template and its name
        $Templates = $this->ConfigSetting("Templates");

        # don't send e-mail if the template is invalid
        if (!isset($Templates[$TemplateId]))
        {
            return;
        }

        # convert incoming parameters to arrays if necessary
        if (!is_array($Users)) {  $Users = array($Users);  }
        if ($Resources && !is_array($Resources))
                {  $Resources = array($Resources);  }

        # load resource IDs if necessary because they're better for serializing
        # for the unique task
        if (count($Resources) && is_object(reset($Resources)))
        {
            foreach ($Resources as $Resource)
            {
                $NewResources[] = intval($Resource->Id());
            }

            $Resources = $NewResources;
        }

        # get the name of the template for the task description
        $TemplateName = $Templates[$TemplateId]["Name"];

        # variable to track how many e-mails are to be sent
        $NumToBeSent = 0;

        # for each user
        foreach ($Users as $User)
        {
            # load the user object if necessary
            if (!is_object($User))
            {
                $User = new CWUser($User);
            }

            # skip invalid users
            if ($User->Status() != U_OKAY)
            {
                continue;
            }

            # get the user ID and e-mail address
            $UserId = $User->Id();
            $UserEmail = $User->Get("EMail");

            # queue the unique task
            $GLOBALS["AF"]->QueueUniqueTask(
                array($this, "SendEmail"),
                array($TemplateId, $UserId, $Resources, $ExtraValues),
                $this->ConfigSetting("EmailTaskPriority"),
                "Send e-mail to \"".$UserEmail."\" using the \""
                .$TemplateName."\" template.");

            # increment the number of e-mail messages to be sent
            $NumToBeSent += 1;
        }

        # return the number to be sent
        return $NumToBeSent;
    }

    # ---- PRIVATE INTERFACE -------------------------------------------------

    # values for use by KeywordReplacmentCallback()
    private $KRUser;
    private $KRItemBody;
    private $KRResources;
    private $KRExtraValues;
    private $KRIsHtml;

    /**
    * Replace keywords in the given body text.
    * @param string $Body Body text.
    * @param bool $IsHtml Set to TRUE to escape necessary characters in keywords.
    * @return Returns the body text with keywords replaced.
    */
    protected function ReplaceKeywordsInBody($Body, $IsHtml=TRUE)
    {
        $NewBody = "";

        # flag whether the output is HTML
        $this->KRIsHtml = $IsHtml;

        # for each line of template
        foreach ($this->SplitByLineEnding($Body) as $Line)
        {
            # replace any keywords in line and add line to message body
            # along with a newline to avoid line truncation by mail transfer
            # agents
            $NewBody .= $this->ReplaceKeywords($Line) . "\n";
        }

        return $NewBody;
    }

    /**
    * Split a string by its line endings, e.g., CR, LF, or CRLF.
    * @param string $Value String to split.
    * @return Returns the string split by its line endings.
    */
    protected function SplitByLineEnding($Value)
    {
        return preg_split('/\r\n|\r|\n/', $Value);
    }

    private function ReplaceKeywords($Line)
    {
        return preg_replace_callback("/X-([A-Z0-9:]+)-X/",
                array($this, "KeywordReplacementCallback"), $Line);
    }

    private function KeywordReplacementCallback($Matches)
    {
        static $InResourceList = FALSE;
        static $CurrentResource;
        static $ResourceNumber;
        static $FieldNameMappings;

        # if extra value was supplied with keyword that matches the match string
        if (count($this->KRExtraValues)
                && isset($this->KRExtraValues[$Matches[1]]))
        {
            # return extra value to caller
            return $this->KRExtraValues[$Matches[1]];
        }

        # if current resource is not yet set then set default value if available
        if (!isset($CurrentResource) && count($this->KRResources))
        {
            $CurrentResource = reset($this->KRResources);
        }

        # start out with assumption that no replacement text will be found
        $Replacement = $Matches[0];

        # switch on match string
        switch ($Matches[1])
        {
            case "PORTALNAME":
                $Replacement = $GLOBALS["G_SysConfig"]->PortalName();
                break;

            case "BASEURL":
                $Replacement = strlen(trim($this->ConfigSetting("BaseUrl")))
                        ? trim($this->ConfigSetting("BaseUrl"))
                        : OurBaseUrl()."index.php";
                break;

            case "ADMINEMAIL":
                $Replacement = $GLOBALS["G_SysConfig"]->AdminEmail();
                break;

            case "LEGALNOTICE":
                $Replacement = $GLOBALS["G_SysConfig"]->LegalNotice();
                break;

            case "USERLOGIN":
            case "USERNAME":
                $Value = $this->KRUser->Get("UserName");
                $Replacement = $this->KRIsHtml ? StripXSSThreats($Value) : $Value;
                break;

            case "USERREALNAME":
                $Value = $this->KRUser->Get("RealName");

                # if the user hasn't specified a full name
                if (!strlen(trim($Value)))
                {
                    $Value = $this->KRUser->Get("UserName");
                }

                $Replacement = $this->KRIsHtml ? StripXSSThreats($Value) : $Value;
                break;

            case "USEREMAIL":
                $Value = $this->KRUser->Get("EMail");
                $Replacement = $this->KRIsHtml ? StripXSSThreats($Value) : $Value;
                break;

            case "RESOURCELIST":
                $Replacement = "";
                if ($InResourceList == FALSE)
                {
                    $InResourceList = TRUE;
                    $ResourceNumber = 1;
                    foreach ($this->KRResources as $Resource)
                    {
                        $CurrentResource = $Resource;
                        $TemplateLines = $this->SplitByLineEnding($this->KRItemBody);
                        foreach ($TemplateLines as $Line)
                        {
                            $Replacement .= preg_replace_callback(
                                    "/X-([A-Z0-9:]+)-X/",
                                    array($this, "KeywordReplacementCallback"),
                                    $Line) . "\n";
                        }
                        $ResourceNumber++;
                    }
                    $InResourceList = FALSE;
                }
                break;

            case "RESOURCENUMBER":
                $Replacement = $ResourceNumber;
                break;

            case "RESOURCECOUNT":
                $Replacement = count($this->KRResources);
                break;

            case "RESOURCEID":
                $Replacement = $CurrentResource->Id();
                break;

            default:
                # map to date/time values if appropriate
                $DateFormats = array(
                        "DATE"            => "M j Y",
                        "TIME"            => "g:ia T",
                        "YEAR"            => "Y",
                        "YEARABBREV"      => "y",
                        "MONTH"           => "n",
                        "MONTHNAME"       => "F",
                        "MONTHABBREV"     => "M",
                        "MONTHZERO"       => "m",
                        "DAY"             => "j",
                        "DAYZERO"         => "d",
                        "DAYWITHSUFFIX"   => "jS",
                        "WEEKDAYNAME"     => "l",
                        "WEEKDAYABBREV"   => "D",
                        "HOUR"            => "g",
                        "HOURZERO"        => "h",
                        "MINUTE"          => "i",
                        "TIMEZONE"        => "T",
                        "AMPMLOWER"       => "a",
                        "AMPMUPPER"       => "A",
                        );

                # map the share format values if appropriate
                if ($GLOBALS["G_PluginManager"]->PluginEnabled("SocialMedia"))
                {
                    $ShareFormats = array(
                        "FACEBOOK", "TWITTER", "LINKEDIN", "GOOGLEPLUS");
                }
                else
                {
                    $ShareFormats = array();
                }

                if (isset($DateFormats[$Matches[1]]))
                {
                    $Replacement = date($DateFormats[$Matches[1]]);
                }
                else if (isset($CurrentResource))
                {
                    # get the schema ID for the resource
                    $SchemaId = $CurrentResource->SchemaId();

                    # load field name mappings (if not already loaded)
                    if (!isset($FieldNameMappings[$SchemaId]))
                    {
                        $Schema = new MetadataSchema($SchemaId);
                        foreach ($Schema->GetFields() as $Field)
                        {
                            $NormalizedName = strtoupper(
                                preg_replace("/[^A-Za-z0-9]/",
                                             "", $Field->Name()));
                            $FieldNameMappings[$SchemaId][$NormalizedName]
                                = $Field;
                        }
                    }

                    # if keyword refers to known field
                    $KeywordIsField = preg_match(
                        "/FIELD:([A-Z0-9]+)/", $Matches[1], $SubMatches);
                    $KeywordIsShare = preg_match(
                        "/SHARE:([A-Z]+)/", $Matches[1], $ShareSubMatches);

                    # if there's a match for a field keyword
                    if ($KeywordIsField
                        && isset($FieldNameMappings[$SchemaId][$SubMatches[1]]))
                    {
                        # replacement is value from current resource
                        $Field = $FieldNameMappings[$SchemaId][$SubMatches[1]];
                        $Replacement = $CurrentResource->Get($Field);
                        if (is_array($Replacement))
                        {
                            foreach ($Replacement as $ReplacementEntry)
                            {
                                if (!isset($RebuiltReplacement))
                                {
                                    $RebuiltReplacement = $ReplacementEntry;
                                }
                                else
                                {
                                    $RebuiltReplacement .= ", ".$ReplacementEntry;
                                }
                            }
                            $Replacement = isset($RebuiltReplacement)
                                ? $RebuiltReplacement : "";
                        }
                        if (!$Field->AllowHTML())
                        {
                            $Replacement = $this->KRIsHtml
                                ? htmlspecialchars($Replacement) : $Replacement;
                            $Replacement = wordwrap($Replacement, 78);
                        }

                        # HTML is allowed but there isn't any HTML in the value,
                        # so wrapping is okay
                        else if (strpos($Replacement, ">") === FALSE
                                 && strpos($Replacement, "<") === FALSE)
                        {
                            $Replacement = wordwrap($Replacement, 78);
                        }

                        # HTML is allowed and in the value but it shouldn't be
                        # used in the plain text version
                        else if (!$this->KRIsHtml)
                        {
                            # try as hard as possible to convert the HTML to plain text
                            $Replacement = Email::ConvertHtmlToPlainText($Replacement);

                            # wrap the body
                            $Replacement = wordwrap($Replacement, 78);
                        }

                        $Replacement = $this->KRIsHtml
                            ? StripXSSThreats($Replacement) : $Replacement;
                    }

                    # if there's a match for a share keyword
                    else if ($KeywordIsShare
                             && in_array($ShareSubMatches[1], $ShareFormats))
                    {
                        $Replacement = $this->GetShareUrl(
                            $CurrentResource,
                            $ShareSubMatches[1]);
                    }
                }
                break;
        }

        # return replacement string to caller
        return $Replacement;
    }

    /**
    * Get the share URL for the given resource to the given site, where site is
    * one of "Facebook", "Twitter", "LinkedIn", or "GooglePlus".
    * @param Resource $Resource The resource for which to get the share URL.
    * @param string $Site The site for which to get the share URL.
    * @return Returns the share URL for the resource and site or NULL if the
    *      SocialMedia plugin isn't available.
    */
    private function GetShareUrl(Resource $Resource, $Site)
    {
        # the social media plugin needs to be available
        if (!$GLOBALS["G_PluginManager"]->PluginEnabled("SocialMedia"))
        {
            return NULL;
        }

        # return the share URL from the social media plugin
        $Plugin = $GLOBALS["G_PluginManager"]->GetPlugin("SocialMedia");
        return $Plugin->GetShareUrl($Resource, $Site);
    }

}
