Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.68% covered (success)
95.68%
133 / 139
82.35% covered (warning)
82.35%
14 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Mailer
95.68% covered (success)
95.68%
133 / 139
82.35% covered (warning)
82.35%
14 / 17
48
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTransport
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNewMessage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 resetConnection
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 getNewBlankMessage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setTransport
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 stringToAddressList
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 buildMultipartBody
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 send
96.72% covered (success)
96.72%
59 / 61
0.00% covered (danger)
0.00%
0 / 1
21
 sendLink
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultLinkSubject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendRecord
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 setMaxRecipients
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultRecordSubject
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFromAddressOverride
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setFromAddressOverride
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 convertToAddressList
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3/**
4 * VuFind Mailer Class
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2009.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Mailer
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development Wiki
28 */
29
30namespace VuFind\Mailer;
31
32use Laminas\Mail\Address;
33use Laminas\Mail\AddressList;
34use Laminas\Mail\Header\ContentType;
35use Laminas\Mail\Transport\TransportInterface;
36use Laminas\Mime\Message as MimeMessage;
37use Laminas\Mime\Mime;
38use Laminas\Mime\Part as MimePart;
39use VuFind\Exception\Mail as MailException;
40
41use function count;
42use function is_callable;
43
44/**
45 * VuFind Mailer Class
46 *
47 * @category VuFind
48 * @package  Mailer
49 * @author   Demian Katz <demian.katz@villanova.edu>
50 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
51 * @link     https://vufind.org/wiki/development Wiki
52 */
53class Mailer implements
54    \VuFind\I18n\Translator\TranslatorAwareInterface,
55    \Laminas\Log\LoggerAwareInterface
56{
57    use \VuFind\I18n\Translator\TranslatorAwareTrait;
58    use \VuFind\Log\LoggerAwareTrait;
59
60    /**
61     * Mail transport
62     *
63     * @var TransportInterface
64     */
65    protected $transport;
66
67    /**
68     * A clone of $transport above. This can be used to reset the connection state
69     * in case transport doesn't support the disconnect method or it throws an
70     * exception (this can happen if the connection is stale and the connector tries
71     * to issue a QUIT message for clean disconnect).
72     *
73     * @var TransportInterface
74     */
75    protected $initialTransport;
76
77    /**
78     * The maximum number of email recipients allowed (0 = no limit)
79     *
80     * @var int
81     */
82    protected $maxRecipients = 1;
83
84    /**
85     * "From" address override
86     *
87     * @var string
88     */
89    protected $fromAddressOverride = '';
90
91    /**
92     * Constructor
93     *
94     * @param TransportInterface $transport  Mail transport
95     * @param ?string            $messageLog File to log messages into (null for no logging)
96     */
97    public function __construct(TransportInterface $transport, protected ?string $messageLog = null)
98    {
99        $this->setTransport($transport);
100    }
101
102    /**
103     * Get the mail transport object.
104     *
105     * @return TransportInterface
106     */
107    public function getTransport()
108    {
109        return $this->transport;
110    }
111
112    /**
113     * Get a text email message object.
114     *
115     * @return Message
116     */
117    public function getNewMessage()
118    {
119        $message = $this->getNewBlankMessage();
120        $headers = $message->getHeaders();
121        $ctype = new ContentType();
122        $ctype->setType(Mime::TYPE_TEXT);
123        $ctype->addParameter('charset', 'UTF-8');
124        $headers->addHeader($ctype);
125        return $message;
126    }
127
128    /**
129     * Reset the connection in the transport. Implements a fluent interface.
130     *
131     * @return Mailer
132     */
133    public function resetConnection()
134    {
135        // If the transport has a disconnect method, call it. Otherwise, and in case
136        // disconnect fails, revert to the transport instance clone made before a
137        // connection was made.
138        $transport = $this->getTransport();
139        if (is_callable([$transport, 'disconnect'])) {
140            try {
141                $transport->disconnect();
142            } catch (\Exception $e) {
143                $this->setTransport($this->initialTransport);
144            }
145        } else {
146            $this->setTransport($this->initialTransport);
147        }
148        return $this;
149    }
150
151    /**
152     * Get a blank email message object.
153     *
154     * @return Message
155     */
156    public function getNewBlankMessage()
157    {
158        $message = new Message();
159        $message->setEncoding('UTF-8');
160        return $message;
161    }
162
163    /**
164     * Set the mail transport object.
165     *
166     * @param TransportInterface $transport Mail transport object
167     *
168     * @return void
169     */
170    public function setTransport($transport)
171    {
172        $this->transport = $transport;
173        // Store a clone of the given transport so that we can reset the connection
174        // as necessary.
175        $this->initialTransport = clone $this->transport;
176    }
177
178    /**
179     * Convert a delimited string to an address list.
180     *
181     * @param string $input String to convert
182     *
183     * @return AddressList
184     */
185    public function stringToAddressList($input)
186    {
187        // Create recipient list
188        $list = new AddressList();
189        foreach (preg_split('/[\s,;]/', $input) as $current) {
190            $current = trim($current);
191            if (!empty($current)) {
192                $list->add($current);
193            }
194        }
195        return $list;
196    }
197
198    /**
199     * Constructs a {@see MimeMessage} body from given text and html content.
200     *
201     * @param string|null $text Mail content used for plain text part
202     * @param string|null $html Mail content used for html part
203     *
204     * @return MimeMessage
205     */
206    public function buildMultipartBody(
207        string $text = null,
208        string $html = null
209    ): MimeMessage {
210        $parts = new MimeMessage();
211
212        if ($text) {
213            $textPart = new MimePart($text);
214            $textPart->setType(Mime::TYPE_TEXT);
215            $textPart->setCharset('utf-8');
216            $textPart->setEncoding(Mime::ENCODING_QUOTEDPRINTABLE);
217            $parts->addPart($textPart);
218        }
219
220        if ($html) {
221            $htmlPart = new MimePart($html);
222            $htmlPart->setType(Mime::TYPE_HTML);
223            $htmlPart->setCharset('utf-8');
224            $htmlPart->setEncoding(Mime::ENCODING_QUOTEDPRINTABLE);
225            $parts->addPart($htmlPart);
226        }
227
228        $alternativePart = new MimePart($parts->generateMessage());
229        $alternativePart->setType('multipart/alternative');
230        $alternativePart->setBoundary($parts->getMime()->boundary());
231        $alternativePart->setCharset('utf-8');
232
233        $body = new MimeMessage();
234        $body->setParts([$alternativePart]);
235
236        return $body;
237    }
238
239    /**
240     * Send an email message.
241     *
242     * @param string|Address|AddressList $to      Recipient email address (or
243     * delimited list)
244     * @param string|Address             $from    Sender name and email address
245     * @param string                     $subject Subject line for message
246     * @param string|MimeMessage         $body    Message body
247     * @param string                     $cc      CC recipient (null for none)
248     * @param string|Address|AddressList $replyTo Reply-To address (or delimited
249     * list, null for none)
250     *
251     * @throws MailException
252     * @return void
253     */
254    public function send($to, $from, $subject, $body, $cc = null, $replyTo = null)
255    {
256        $recipients = $this->convertToAddressList($to);
257        $replyTo = $this->convertToAddressList($replyTo);
258
259        // Validate email addresses:
260        if ($this->maxRecipients > 0) {
261            if ($this->maxRecipients < count($recipients)) {
262                throw new MailException(
263                    'Too Many Email Recipients',
264                    MailException::ERROR_TOO_MANY_RECIPIENTS
265                );
266            }
267        }
268        $validator = new \Laminas\Validator\EmailAddress();
269        if (count($recipients) == 0) {
270            throw new MailException(
271                'Invalid Recipient Email Address',
272                MailException::ERROR_INVALID_RECIPIENT
273            );
274        }
275        foreach ($recipients as $current) {
276            if (!$validator->isValid($current->getEmail())) {
277                throw new MailException(
278                    'Invalid Recipient Email Address',
279                    MailException::ERROR_INVALID_RECIPIENT
280                );
281            }
282        }
283        foreach ($replyTo as $current) {
284            if (!$validator->isValid($current->getEmail())) {
285                throw new MailException(
286                    'Invalid Reply-To Email Address',
287                    MailException::ERROR_INVALID_REPLY_TO
288                );
289            }
290        }
291        $fromEmail = ($from instanceof Address)
292            ? $from->getEmail() : $from;
293        if (!$validator->isValid($fromEmail)) {
294            throw new MailException(
295                'Invalid Sender Email Address',
296                MailException::ERROR_INVALID_SENDER
297            );
298        }
299
300        if (
301            !empty($this->fromAddressOverride)
302            && $this->fromAddressOverride != $fromEmail
303        ) {
304            // Add the original from address as the reply-to address unless
305            // a reply-to address has been specified
306            if (count($replyTo) === 0) {
307                $replyTo->add($fromEmail);
308            }
309            if (!($from instanceof Address)) {
310                $from = new Address($from);
311            }
312            $name = $from->getName();
313            if (!$name) {
314                [$fromPre] = explode('@', $from->getEmail());
315                $name = $fromPre ? $fromPre : null;
316            }
317            $from = new Address($this->fromAddressOverride, $name);
318        }
319
320        // Convert all exceptions thrown by mailer into MailException objects:
321        try {
322            // Send message
323            $message = $body instanceof MimeMessage
324                ? $this->getNewBlankMessage()
325                : $this->getNewMessage();
326            $message->addFrom($from)
327                ->addTo($recipients)
328                ->setBody($body)
329                ->setSubject($subject);
330            if ($cc !== null) {
331                $message->addCc($cc);
332            }
333            if ($replyTo) {
334                $message->addReplyTo($replyTo);
335            }
336            $this->getTransport()->send($message);
337            if ($this->messageLog) {
338                file_put_contents($this->messageLog, $message->toString() . "\n", FILE_APPEND);
339            }
340        } catch (\Exception $e) {
341            $this->logError($e->getMessage());
342            throw new MailException($e->getMessage(), MailException::ERROR_UNKNOWN);
343        }
344    }
345
346    /**
347     * Send an email message representing a link.
348     *
349     * @param string                             $to      Recipient email address
350     * @param string|\Laminas\Mail\Address       $from    Sender name and email
351     * address
352     * @param string                             $msg     User notes to include in
353     * message
354     * @param string                             $url     URL to share
355     * @param \Laminas\View\Renderer\PhpRenderer $view    View object (used to render
356     * email templates)
357     * @param string                             $subject Subject for email
358     * (optional)
359     * @param string                             $cc      CC recipient (null for
360     * none)
361     * @param string|Address|AddressList         $replyTo Reply-To address (or
362     * delimited list, null for none)
363     *
364     * @throws MailException
365     * @return void
366     */
367    public function sendLink(
368        $to,
369        $from,
370        $msg,
371        $url,
372        $view,
373        $subject = null,
374        $cc = null,
375        $replyTo = null
376    ) {
377        if (null === $subject) {
378            $subject = $this->getDefaultLinkSubject();
379        }
380        $body = $view->partial(
381            'Email/share-link.phtml',
382            [
383                'msgUrl' => $url, 'to' => $to, 'from' => $from, 'message' => $msg,
384            ]
385        );
386        $this->send($to, $from, $subject, $body, $cc, $replyTo);
387    }
388
389    /**
390     * Get the default subject line for sendLink().
391     *
392     * @return string
393     */
394    public function getDefaultLinkSubject()
395    {
396        return $this->translate('Library Catalog Search Result');
397    }
398
399    /**
400     * Send an email message representing a record.
401     *
402     * @param string                             $to      Recipient email address
403     * @param string|\Laminas\Mail\Address       $from    Sender name and email
404     * address
405     * @param string                             $msg     User notes to include in
406     * message
407     * @param \VuFind\RecordDriver\AbstractBase  $record  Record being emailed
408     * @param \Laminas\View\Renderer\PhpRenderer $view    View object (used to render
409     * email templates)
410     * @param string                             $subject Subject for email
411     * (optional)
412     * @param string                             $cc      CC recipient (null for
413     * none)
414     * @param string|Address|AddressList         $replyTo Reply-To address (or
415     * delimited list, null for none)
416     *
417     * @throws MailException
418     * @return void
419     */
420    public function sendRecord(
421        $to,
422        $from,
423        $msg,
424        $record,
425        $view,
426        $subject = null,
427        $cc = null,
428        $replyTo = null
429    ) {
430        if (null === $subject) {
431            $subject = $this->getDefaultRecordSubject($record);
432        }
433        $body = $view->partial(
434            'Email/record.phtml',
435            [
436                'driver' => $record, 'to' => $to, 'from' => $from, 'message' => $msg,
437            ]
438        );
439        $this->send($to, $from, $subject, $body, $cc, $replyTo);
440    }
441
442    /**
443     * Set the maximum number of email recipients
444     *
445     * @param int $max Maximum
446     *
447     * @return void
448     */
449    public function setMaxRecipients($max)
450    {
451        $this->maxRecipients = $max;
452    }
453
454    /**
455     * Get the default subject line for sendRecord()
456     *
457     * @param \VuFind\RecordDriver\AbstractBase $record Record being emailed
458     *
459     * @return string
460     */
461    public function getDefaultRecordSubject($record)
462    {
463        return $this->translate('Library Catalog Record') . ': '
464            . $record->getBreadcrumb();
465    }
466
467    /**
468     * Get the "From" address override value
469     *
470     * @return string
471     */
472    public function getFromAddressOverride()
473    {
474        return $this->fromAddressOverride;
475    }
476
477    /**
478     * Set the "From" address override
479     *
480     * @param string $address "From" address
481     *
482     * @return void
483     */
484    public function setFromAddressOverride($address)
485    {
486        $this->fromAddressOverride = $address;
487    }
488
489    /**
490     * Convert the given addresses to an AddressList object
491     *
492     * @param string|Address|AddressList $addresses Addresses
493     *
494     * @return AddressList
495     */
496    protected function convertToAddressList($addresses)
497    {
498        if ($addresses instanceof AddressList) {
499            $result = $addresses;
500        } elseif ($addresses instanceof Address) {
501            $result = new AddressList();
502            $result->add($addresses);
503        } else {
504            $result = $this->stringToAddressList($addresses ? $addresses : '');
505        }
506        return $result;
507    }
508}