From 452e76ceaf53b65e518b5d419cac12cd261943e8 Mon Sep 17 00:00:00 2001
From: Ere Maijala <ere.maijala@helsinki.fi>
Date: Fri, 23 Feb 2018 20:43:29 +0200
Subject: [PATCH] Mailer improvements (#1112)

- Make it possible to define Reply-To addresses in Mailer's send functions and add a setting to override the sender's address with something SPF-friendly.
---
 config/vufind/config.ini                      |   4 +
 module/VuFind/src/VuFind/Mailer/Factory.php   |   6 +-
 module/VuFind/src/VuFind/Mailer/Mailer.php    | 113 +++++++++++++++---
 .../src/VuFindTest/Mailer/MailerTest.php      |  58 +++++++++
 4 files changed, 161 insertions(+), 20 deletions(-)

diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index 2706f63a0d7..6a554e409fc 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -472,6 +472,10 @@ maximum_recipients = 1
 ; will be sent based on user_email_in_from and default_from above, with the email
 ; setting from the [Site] section used as a last resort.
 disable_from = false
+; From field override. Setting this allows keeping the "from" field in email forms
+; but will only use it as a reply-to address. The address defined here is used as the
+; actual "from" address.
+;override_from = "no-reply@myuniversity.edu"
 
 ; Being a special case of mail message, sending record results via SMS ("Text this")
 ; may be "enabled" or "disabled" ("enabled" by default).
diff --git a/module/VuFind/src/VuFind/Mailer/Factory.php b/module/VuFind/src/VuFind/Mailer/Factory.php
index e18bb596ee7..31a07a9eb80 100644
--- a/module/VuFind/src/VuFind/Mailer/Factory.php
+++ b/module/VuFind/src/VuFind/Mailer/Factory.php
@@ -110,6 +110,10 @@ class Factory implements FactoryInterface
         $config = $container->get('VuFind\Config\PluginManager')->get('config');
 
         // Create service:
-        return new $requestedName($this->getTransport($config));
+        $class = new $requestedName($this->getTransport($config));
+        if (!empty($config->Mail->override_from)) {
+            $class->setFromAddressOverride($config->Mail->override_from);
+        }
+        return $class;
     }
 }
diff --git a/module/VuFind/src/VuFind/Mailer/Mailer.php b/module/VuFind/src/VuFind/Mailer/Mailer.php
index 8defb61460e..3d134159b2d 100644
--- a/module/VuFind/src/VuFind/Mailer/Mailer.php
+++ b/module/VuFind/src/VuFind/Mailer/Mailer.php
@@ -60,6 +60,13 @@ class Mailer implements \VuFind\I18n\Translator\TranslatorAwareInterface
      */
     protected $maxRecipients = 1;
 
+    /**
+     * "From" address override
+     *
+     * @var string
+     */
+    protected $fromAddressOverride = '';
+
     /**
      * Constructor
      *
@@ -139,26 +146,25 @@ class Mailer implements \VuFind\I18n\Translator\TranslatorAwareInterface
      * @param string                     $subject Subject line for message
      * @param string                     $body    Message body
      * @param string                     $cc      CC recipient (null for none)
+     * @param string|Address|AddressList $replyTo Reply-To address (or delimited
+     * list, null for none)
      *
      * @throws MailException
      * @return void
      */
-    public function send($to, $from, $subject, $body, $cc = null)
+    public function send($to, $from, $subject, $body, $cc = null, $replyTo = null)
     {
-        if ($to instanceof AddressList) {
-            $recipients = $to;
-        } elseif ($to instanceof Address) {
-            $recipients = new AddressList();
-            $recipients->add($to);
-        } else {
-            $recipients = $this->stringToAddressList($to);
-        }
+        $recipients = $this->convertToAddressList($to);
+        $replyTo = $this->convertToAddressList($replyTo);
 
         // Validate email addresses:
-        if ($this->maxRecipients > 0
-            && $this->maxRecipients < count($recipients)
-        ) {
-            throw new MailException('Too Many Email Recipients');
+        if ($this->maxRecipients > 0) {
+            if ($this->maxRecipients < count($recipients)) {
+                throw new MailException('Too Many Email Recipients');
+            }
+            if ($this->maxRecipients < count($replyTo)) {
+                throw new MailException('Too Many Email Reply-To Addresses');
+            }
         }
         $validator = new \Zend\Validator\EmailAddress();
         if (count($recipients) == 0) {
@@ -169,11 +175,32 @@ class Mailer implements \VuFind\I18n\Translator\TranslatorAwareInterface
                 throw new MailException('Invalid Recipient Email Address');
             }
         }
+        foreach ($replyTo as $current) {
+            if (!$validator->isValid($current->getEmail())) {
+                throw new MailException('Invalid Reply-To Email Address');
+            }
+        }
         $fromEmail = ($from instanceof Address)
             ? $from->getEmail() : $from;
         if (!$validator->isValid($fromEmail)) {
             throw new MailException('Invalid Sender Email Address');
         }
+
+        if (!empty($this->fromAddressOverride)
+            && $this->fromAddressOverride != $fromEmail
+        ) {
+            $replyTo->add($fromEmail);
+            if (!($from instanceof Address)) {
+                $from = new Address($from);
+            }
+            $name = $from->getName();
+            if (!$name) {
+                list($fromPre) = explode('@', $from->getEmail());
+                $name = $fromPre ? $fromPre : null;
+            }
+            $from = new Address($this->fromAddressOverride, $name);
+        }
+
         // Convert all exceptions thrown by mailer into MailException objects:
         try {
             // Send message
@@ -181,11 +208,13 @@ class Mailer implements \VuFind\I18n\Translator\TranslatorAwareInterface
                 ->addFrom($from)
                 ->addTo($recipients)
                 ->setBody($body)
-                ->setSubject($subject)
-                ->setReplyTo($from);
+                ->setSubject($subject);
             if ($cc !== null) {
                 $message->addCc($cc);
             }
+            if ($replyTo) {
+                $message->addReplyTo($replyTo);
+            }
             $this->getTransport()->send($message);
         } catch (\Exception $e) {
             throw new MailException($e->getMessage());
@@ -204,12 +233,14 @@ class Mailer implements \VuFind\I18n\Translator\TranslatorAwareInterface
      * email templates)
      * @param string                          $subject Subject for email (optional)
      * @param string                          $cc      CC recipient (null for none)
+     * @param string|Address|AddressList      $replyTo Reply-To address (or delimited
+     * list, null for none)
      *
      * @throws MailException
      * @return void
      */
     public function sendLink($to, $from, $msg, $url, $view, $subject = null,
-        $cc = null
+        $cc = null, $replyTo = null
     ) {
         if (null === $subject) {
             $subject = $this->getDefaultLinkSubject();
@@ -220,7 +251,7 @@ class Mailer implements \VuFind\I18n\Translator\TranslatorAwareInterface
                 'msgUrl' => $url, 'to' => $to, 'from' => $from, 'message' => $msg
             ]
         );
-        return $this->send($to, $from, $subject, $body, $cc);
+        return $this->send($to, $from, $subject, $body, $cc, $replyTo);
     }
 
     /**
@@ -246,12 +277,14 @@ class Mailer implements \VuFind\I18n\Translator\TranslatorAwareInterface
      * email templates)
      * @param string                            $subject Subject for email (optional)
      * @param string                            $cc      CC recipient (null for none)
+     * @param string|Address|AddressList        $replyTo Reply-To address (or
+     * delimited list, null for none)
      *
      * @throws MailException
      * @return void
      */
     public function sendRecord($to, $from, $msg, $record, $view, $subject = null,
-        $cc = null
+        $cc = null, $replyTo = null
     ) {
         if (null === $subject) {
             $subject = $this->getDefaultRecordSubject($record);
@@ -262,7 +295,7 @@ class Mailer implements \VuFind\I18n\Translator\TranslatorAwareInterface
                 'driver' => $record, 'to' => $to, 'from' => $from, 'message' => $msg
             ]
         );
-        return $this->send($to, $from, $subject, $body, $cc);
+        return $this->send($to, $from, $subject, $body, $cc, $replyTo);
     }
 
     /**
@@ -289,4 +322,46 @@ class Mailer implements \VuFind\I18n\Translator\TranslatorAwareInterface
         return $this->translate('Library Catalog Record') . ': '
             . $record->getBreadcrumb();
     }
+
+    /**
+     * Get the "From" address override value
+     *
+     * @return string
+     */
+    public function getFromAddressOverride()
+    {
+        return $this->fromAddressOverride;
+    }
+
+    /**
+     * Set the "From" address override
+     *
+     * @param string $address "From" address
+     *
+     * @return void
+     */
+    public function setFromAddressOverride($address)
+    {
+        $this->fromAddressOverride = $address;
+    }
+
+    /**
+     * Convert the given addresses to an AddressList object
+     *
+     * @param string|Address|AddressList $addresses Addresses
+     *
+     * @return AddressList
+     */
+    protected function convertToAddressList($addresses)
+    {
+        if ($addresses instanceof AddressList) {
+            $result = $addresses;
+        } elseif ($addresses instanceof Address) {
+            $result = new AddressList();
+            $result->add($addresses);
+        } else {
+            $result = $this->stringToAddressList($addresses ? $addresses : '');
+        }
+        return $result;
+    }
 }
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Mailer/MailerTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Mailer/MailerTest.php
index f2db494217d..99e479e324b 100644
--- a/module/VuFind/tests/unit-tests/src/VuFindTest/Mailer/MailerTest.php
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Mailer/MailerTest.php
@@ -125,6 +125,29 @@ class MailerTest extends \VuFindTest\Unit\TestCase
         $mailer->send($list, 'from@example.com', 'subject', 'body');
     }
 
+    /**
+     * Test sending an email using a from address override.
+     *
+     * @return void
+     */
+    public function testSendWithFromOverride()
+    {
+        $callback = function ($message) {
+            $fromString = $message->getFrom()->current()->toString();
+            return '<to@example.com>' == $message->getTo()->current()->toString()
+                && '<me@example.com>' == $message->getReplyTo()->current()->toString()
+                && 'me <no-reply@example.com>' == $fromString
+                && 'body' == $message->getBody()
+                && 'subject' == $message->getSubject();
+        };
+        $transport = $this->createMock('Zend\Mail\Transport\TransportInterface');
+        $transport->expects($this->once())->method('send')->with($this->callback($callback));
+        $address = new Address('me@example.com');
+        $mailer = new Mailer($transport);
+        $mailer->setFromAddressOverride('no-reply@example.com');
+        $mailer->send('to@example.com', $address, 'subject', 'body');
+    }
+
     /**
      * Test bad to address.
      *
@@ -140,6 +163,23 @@ class MailerTest extends \VuFindTest\Unit\TestCase
         $mailer->send('bad@bad', 'from@example.com', 'subject', 'body');
     }
 
+    /**
+     * Test bad reply-to address.
+     *
+     * @return void
+     *
+     * @expectedException        VuFind\Exception\Mail
+     * @expectedExceptionMessage Invalid Reply-To Email Address
+     */
+    public function testBadReplyTo()
+    {
+        $transport = $this->createMock('Zend\Mail\Transport\TransportInterface');
+        $mailer = new Mailer($transport);
+        $mailer->send(
+            'good@good.com', 'from@example.com', 'subject', 'body', null, 'bad@bad'
+        );
+    }
+
     /**
      * Test empty to address.
      *
@@ -170,6 +210,24 @@ class MailerTest extends \VuFindTest\Unit\TestCase
         $mailer->send('one@test.com;two@test.com', 'from@example.com', 'subject', 'body');
     }
 
+    /**
+     * Test that we only accept one reply-to address by default
+     *
+     * @return void
+     *
+     * @expectedException        VuFind\Exception\Mail
+     * @expectedExceptionMessage Too Many Email Reply-To Addresses
+     */
+    public function testTooManyReplyToAddresses()
+    {
+        $transport = $this->createMock('Zend\Mail\Transport\TransportInterface');
+        $mailer = new Mailer($transport);
+        $mailer->send(
+            'one@test.com', 'from@example.com', 'subject', 'body',
+            null, 'one@test.com;two@test.com'
+        );
+    }
+
     /**
      * Test bad from address.
      *
-- 
GitLab