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

class Mailer extends Plugin {

    # ---- PUBLIC INTERFACE --------------------------------------------------

    function Register()
    {
        $this->Name = "Mailer";
        $this->Version = "1.0.3";
        $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.",
                );
    }

    function Install()
    {
        $this->ConfigSetting("Templates", array());
    }

    /**
    * Upgrade from a previous version.
    * @param $PreviousVersion Previous version number as a string.
    */
    public 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);
        }
    }

    function HookEvents()
    {
        return array(
                "EVENT_COLLECTION_ADMINISTRATION_MENU" => "AddCollectionAdminMenuItems",
                );
    }

    function AddCollectionAdminMenuItems()
    {
        return array(
                "EditMessageTemplates" => "Edit Email Templates",
                );
    }

    /**
    * 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 User($DB, $User);
                if ($User->Status() != U_OKAY) {  continue;  }
            }
            $this->KRUser = $User;

            # 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;
    }


    # ---- 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 value 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",
                        );
                if (isset($DateFormats[$Matches[1]]))
                {
                    $Replacement = date($DateFormats[$Matches[1]]);
                }
                else
                {
                    # load field name mappings (if not already loaded)
                    if (!isset($FieldNameMappings))
                    {
                        $Schema = new MetadataSchema();
                        $Fields = $Schema->GetFields();
                        foreach ($Fields as $Field)
                        {
                            $NormalizedName = strtoupper(
                                    preg_replace("/[^A-Za-z0-9]/",
                                    "", $Field->Name()));
                            $FieldNameMappings[$NormalizedName] = $Field;
                        }
                    }

                    # if keyword refers to known field and we have a current resource
                    $KeywordIsField = preg_match(
                            "/FIELD:([A-Z0-9]+)/", $Matches[1], $SubMatches);
                    if ($KeywordIsField && isset($FieldNameMappings[$SubMatches[1]])
                            && isset($CurrentResource))
                    {
                        # replacement is value from current resource
                        $Field = $FieldNameMappings[$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)
                        {
                            # remove newlines
                            $Replacement = str_replace(array("\r", "\n"), "", $Replacement);

                            # convert HTML breaks to newlines
                            $Replacement = preg_replace(
                                '/<br\s*\/?>/',
                                "\n",
                                $Replacement);

                            # strip remaining tags
                            $Replacement = strip_tags($Replacement);

                            # convert HTML entities to their plain-text
                            # equivalents
                            $Replacement = html_entity_decode($Replacement);

                            # remove HTML entities that have no equivalents
                            $Replacement = preg_replace(
                                '/&[a-zA-Z0-9]{1,6};/',
                                "",
                                $Replacement);

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

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

        # return replacement string to caller
        return $Replacement;
    }
}
