1<?php
2/*********************************************************************
3    class.mailer.php
4
5    osTicket mailer
6
7    It's mainly PEAR MAIL wrapper for now (more improvements planned).
8
9    Peter Rotich <peter@osticket.com>
10    Copyright (c)  2006-2013 osTicket
11    http://www.osticket.com
12
13    Released under the GNU General Public License WITHOUT ANY WARRANTY.
14    See LICENSE.TXT for details.
15
16    vim: expandtab sw=4 ts=4 sts=4:
17**********************************************************************/
18
19include_once(INCLUDE_DIR.'class.email.php');
20require_once(INCLUDE_DIR.'html2text.php');
21
22class Mailer {
23
24    var $email;
25
26    var $ht = array();
27    var $attachments = array();
28    var $options = array();
29
30    var $smtp = array();
31    var $eol="\n";
32
33    function __construct($email=null, array $options=array()) {
34        global $cfg;
35
36        if(is_object($email) && $email->isSMTPEnabled() && ($info=$email->getSMTPInfo())) { //is SMTP enabled for the current email?
37            $this->smtp = $info;
38        } elseif($cfg && ($e=$cfg->getDefaultSMTPEmail()) && $e->isSMTPEnabled()) { //What about global SMTP setting?
39            $this->smtp = $e->getSMTPInfo();
40            if(!$e->allowSpoofing() || !$email)
41                $email = $e;
42        } elseif(!$email && $cfg && ($e=$cfg->getDefaultEmail())) {
43            if($e->isSMTPEnabled() && ($info=$e->getSMTPInfo()))
44                $this->smtp = $info;
45            $email = $e;
46        }
47
48        $this->email = $email;
49        $this->attachments = array();
50        $this->options = $options;
51    }
52
53    function getEOL() {
54        return $this->eol;
55    }
56
57    function getEmail() {
58        return $this->email;
59    }
60
61    function getSMTPInfo() {
62        return $this->smtp;
63    }
64    /* FROM Address */
65    function setFromAddress($from) {
66        $this->ht['from'] = $from;
67    }
68
69    function getFromAddress($options=array()) {
70
71        if (!$this->ht['from'] && ($email=$this->getEmail())) {
72            if (($name = $options['from_name'] ?: $email->getName()))
73                $this->ht['from'] =sprintf('"%s" <%s>', $name, $email->getEmail());
74            else
75                $this->ht['from'] =sprintf('<%s>', $email->getEmail());
76        }
77
78        return $this->ht['from'];
79    }
80
81    /* attachments */
82    function getAttachments() {
83        return $this->attachments;
84    }
85
86    function addAttachment(Attachment $attachment) {
87        // XXX: This looks too assuming; however, the attachment processor
88        // in the ::send() method seems hard coded to expect this format
89        $this->attachments[] = $attachment;
90    }
91
92    function addAttachmentFile(AttachmentFile $file) {
93        // XXX: This looks too assuming; however, the attachment processor
94        // in the ::send() method seems hard coded to expect this format
95        $this->attachments[] = $file;
96    }
97
98    function addFileObject(FileObject $file) {
99        $this->attachments[] = $file;
100    }
101
102    function addAttachments($attachments) {
103        foreach ($attachments as $a) {
104            if ($a instanceof Attachment)
105                $this->addAttachment($a);
106            elseif ($a instanceof AttachmentFile)
107                $this->addAttachmentFile($a);
108            elseif ($a instanceof FileObject)
109                $this->addFileObject($a);
110        }
111    }
112
113    /**
114     * getMessageId
115     *
116     * Generates a unique message ID for an outbound message. Optionally,
117     * the recipient can be used to create a tag for the message ID where
118     * the user-id and thread-entry-id are encoded in the message-id so
119     * the message can be threaded if it is replied to without any other
120     * indicator of the thread to which it belongs. This tag is signed with
121     * the secret-salt of the installation to guard against false positives.
122     *
123     * Parameters:
124     * $recipient - (EmailContact|null) recipient of the message. The ID of
125     *      the recipient is placed in the message id TAG section so it can
126     *      be recovered if the email replied to directly by the end user.
127     * $options - (array) - options passed to ::send(). If it includes a
128     *      'thread' element, the threadId will be recorded in the TAG
129     *
130     * Returns:
131     * (string) - email message id, without leading and trailing <> chars.
132     * See the Format below for the structure.
133     *
134     * Format:
135     * VA-B-C, with dash separators and A-C explained below:
136     *
137     * V: Version code of the generated Message-Id
138     * A: Predictable random code — used for loop detection (sysid)
139     * B: Random data for unique identifier (rand)
140     * C: TAG: Base64(Pack(userid, entryId, threadId, type, Signature)),
141     *    '=' chars discarded
142     * where Signature is:
143     *   Signed Tag value, last 5 chars from
144     *        HMAC(sha1, Tag + rand + sysid, SECRET_SALT),
145     *   where Tag is:
146     *     pack(userId, entryId, threadId, type)
147     */
148    function getMessageId($recipient, $options=array(), $version='B') {
149        $tag = '';
150        $rand = Misc::randCode(5,
151            // RFC822 specifies the LHS of the addr-spec can have any char
152            // except the specials — ()<>@,;:\".[], dash is reserved as the
153            // section separator, and + is reserved for historical reasons
154            'abcdefghiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_=');
155        $sig = $this->getEmail()?$this->getEmail()->getEmail():'@osTicketMailer';
156        $sysid = static::getSystemMessageIdCode();
157        // Create a tag for the outbound email
158        $entry = (isset($options['thread']) && $options['thread'] instanceof ThreadEntry)
159            ? $options['thread'] : false;
160        $thread = $entry ? $entry->getThread()
161            : (isset($options['thread']) && $options['thread'] instanceof Thread
162                ? $options['thread'] : false);
163
164        switch (true) {
165        case $recipient instanceof Staff:
166            $utype = 'S';
167            break;
168        case $recipient instanceof TicketOwner:
169            $utype = 'U';
170            break;
171        case $recipient instanceof Collaborator:
172            $utype = 'C';
173            break;
174        case  $recipient instanceof MailingList:
175            $utype = 'M';
176            break;
177        default:
178            $utype = $options['utype'] ?: is_array($recipient) ? 'M' : '?';
179        }
180
181
182        $tag = pack('VVVa',
183            $recipient instanceof EmailContact ? $recipient->getUserId() : 0,
184            $entry ? $entry->getId() : 0,
185            $thread ? $thread->getId() : 0,
186            $utype ?: '?'
187        );
188        // Sign the tag with the system secret salt
189        $tag .= substr(hash_hmac('sha1', $tag.$rand.$sysid, SECRET_SALT, true), -5);
190        $tag = str_replace('=','',base64_encode($tag));
191        return sprintf('B%s-%s-%s-%s',
192            $sysid, $rand, $tag, $sig);
193    }
194
195    /**
196     * decodeMessageId
197     *
198     * Decodes a message-id generated by osTicket using the ::getMessageId()
199     * method of this class. This will digest the received message-id token
200     * and return an array with some information about it.
201     *
202     * Parameters:
203     * $mid - (string) message-id from an email Message-Id, In-Reply-To, and
204     *      References header.
205     *
206     * Returns:
207     * (array) of information containing all or some of the following keys
208     *      'loopback' - (bool) true or false if the message originated by
209     *          this osTicket installation.
210     *      'version' - (string|FALSE) version code of the message id
211     *      'code' - (string) unique but predictable help desk message-id
212     *      'id' - (string) random characters serving as the unique id
213     *      'entryId' - (int) thread-entry-id from which the message originated
214     *      'threadId' - (int) thread-id from which the message originated
215     *      'staffId' - (int|null) staff the email was originally sent to
216     *      'userId' - (int|null) user the email was originally sent to
217     *      'userClass' - (string) class of user the email was sent to
218     *          'U' - TicketOwner
219     *          'S' - Staff
220     *          'C' - Collborator
221     *          'M' - Multiple
222     *          '?' - Something else
223     */
224    static function decodeMessageId($mid) {
225        // Drop <> tokens
226        $mid = trim($mid, '<> ');
227        // Drop email domain on rhs
228        list($lhs, $sig) = explode('@', $mid, 2);
229        // LHS should be tokenized by '-'
230        $parts = explode('-', $lhs);
231
232        $rv = array('loopback' => false, 'version' => false);
233
234        // There should be at least two tokens if the message was sent by
235        // this system. Otherwise, there's nothing to be detected
236        if (count($parts) < 2)
237            return $rv;
238
239        $self = get_called_class();
240        $decoders = array(
241        'A' => function($id, $tag) use ($sig) {
242            // Old format was VA-B-C-D@sig, where C was the packed tag and D
243            // was blank
244            $format = 'Vuid/VentryId/auserClass';
245            $chksig = substr(hash_hmac('sha1', $tag.$id, SECRET_SALT), -10);
246            if ($tag && $sig == $chksig && ($tag = base64_decode($tag))) {
247                // Find user and ticket id
248                return unpack($format, $tag);
249            }
250            return false;
251        },
252        'B' => function($id, $tag) use ($self) {
253            $format = 'Vuid/VentryId/VthreadId/auserClass/a*sig';
254            if ($tag && ($tag = base64_decode($tag))) {
255                if (!($info = @unpack($format, $tag)) || !isset($info['sig']))
256                    return false;
257                $sysid = $self::getSystemMessageIdCode();
258                $shorttag = substr($tag, 0, 13);
259                $chksig = substr(hash_hmac('sha1', $shorttag.$id.$sysid,
260                    SECRET_SALT, true), -5);
261                if ($chksig == $info['sig']) {
262                    return $info;
263                }
264            }
265            return false;
266        },
267        );
268
269        // Detect the MessageId version, which should be the first char
270        $rv['version'] = @$parts[0][0];
271        if (!isset($decoders[$rv['version']]))
272            // invalid version code
273            return null;
274
275        // Drop the leading version code
276        list($rv['code'], $rv['id'], $tag) = $parts;
277        $rv['code'] = substr($rv['code'], 1);
278
279        // Verify tag signature and unpack the tag
280        $info = $decoders[$rv['version']]($rv['id'], $tag);
281        if ($info === false)
282            return $rv;
283
284        $rv += $info;
285
286        // Attempt to make the user-id more specific
287        $classes = array(
288            'S' => 'staffId', 'U' => 'userId', 'C' => 'userId',
289        );
290        if (isset($classes[$rv['userClass']]))
291            $rv[$classes[$rv['userClass']]] = $rv['uid'];
292
293        // Round-trip detection - the first section is the local
294        // system's message-id code
295        $rv['loopback'] = (0 === strcmp($rv['code'],
296            static::getSystemMessageIdCode()));
297
298        return $rv;
299    }
300
301    static function getSystemMessageIdCode() {
302        return substr(str_replace('+', '=',
303            base64_encode(md5('mail'.SECRET_SALT, true))),
304            0, 6);
305    }
306
307    function send($recipients, $subject, $message, $options=null) {
308        global $ost, $cfg;
309
310        //Get the goodies
311        require_once (PEAR_DIR.'Mail.php'); // PEAR Mail package
312        require_once (PEAR_DIR.'Mail/mime.php'); // PEAR Mail_Mime packge
313
314        $messageId = $this->getMessageId($recipients, $options);
315        $subject = preg_replace("/(\r\n|\r|\n)/s",'', trim($subject));
316        $headers = array (
317            'From' => $this->getFromAddress($options),
318            'Subject' => $subject,
319            'Date'=> date('D, d M Y H:i:s O'),
320            'Message-ID' => "<{$messageId}>",
321            'X-Mailer' =>'osTicket Mailer',
322        );
323
324        // Add in the options passed to the constructor
325        $options = ($options ?: array()) + $this->options;
326
327        // Message Id Token
328        $mid_token = '';
329        // Check if the email is threadable
330        if (isset($options['thread'])
331            && $options['thread'] instanceof ThreadEntry
332            && ($thread = $options['thread']->getThread())) {
333
334            // Add email in-reply-to references if not set
335            if (!isset($options['inreplyto'])) {
336
337                $entry = null;
338                switch (true) {
339                case $recipients instanceof MailingList:
340                    $entry = $thread->getLastEmailMessage();
341                    break;
342                case $recipients instanceof TicketOwner:
343                case $recipients instanceof Collaborator:
344                    $entry = $thread->getLastEmailMessage(array(
345                                'user_id' => $recipients->getUserId()));
346                    break;
347                case $recipients instanceof Staff:
348                    //XXX: is it necessary ??
349                    break;
350                }
351
352                if ($entry && ($mid=$entry->getEmailMessageId())) {
353                    $options['inreplyto'] = $mid;
354                    $options['references'] = $entry->getEmailReferences();
355                }
356            }
357
358            // Embedded message id token
359            $mid_token = $messageId;
360            // Set Reply-Tag
361            if (!isset($options['reply-tag'])) {
362                if ($cfg && $cfg->stripQuotedReply())
363                    $options['reply-tag'] = $cfg->getReplySeparator() . '<br/><br/>';
364                else
365                    $options['reply-tag'] = '';
366            } elseif ($options['reply-tag'] === false) {
367                $options['reply-tag'] = '';
368            }
369        }
370
371        // Return-Path
372        if (isset($options['nobounce']) && $options['nobounce'])
373            $headers['Return-Path'] = '<>';
374        elseif ($this->getEmail() instanceof Email)
375            $headers['Return-Path'] = $this->getEmail()->getEmail();
376
377        // Bulk.
378        if (isset($options['bulk']) && $options['bulk'])
379            $headers+= array('Precedence' => 'bulk');
380
381        // Auto-reply - mark as autoreply and supress all auto-replies
382        if (isset($options['autoreply']) && $options['autoreply']) {
383            $headers+= array(
384                    'Precedence' => 'auto_reply',
385                    'X-Autoreply' => 'yes',
386                    'X-Auto-Response-Suppress' => 'DR, RN, OOF, AutoReply',
387                    'Auto-Submitted' => 'auto-replied');
388        }
389
390        // Notice (sort of automated - but we don't want auto-replies back
391        if (isset($options['notice']) && $options['notice'])
392            $headers+= array(
393                    'X-Auto-Response-Suppress' => 'OOF, AutoReply',
394                    'Auto-Submitted' => 'auto-generated');
395        // In-Reply-To
396        if (isset($options['inreplyto']) && $options['inreplyto'])
397            $headers += array('In-Reply-To' => $options['inreplyto']);
398
399        // References
400        if (isset($options['references']) && $options['references']) {
401            if (is_array($options['references']))
402                $headers += array('References' =>
403                    implode(' ', $options['references']));
404            else
405                $headers += array('References' => $options['references']);
406        }
407
408        // Use general failsafe default initially
409        $eol = "\n";
410        // MAIL_EOL setting can be defined in `ost-config.php`
411        if (defined('MAIL_EOL') && is_string(MAIL_EOL))
412            $eol = MAIL_EOL;
413        $mime = new Mail_mime($eol);
414        // Add recipients
415        if (!is_array($recipients) && (!$recipients instanceof MailingList))
416            $recipients =  array($recipients);
417        foreach ($recipients as $recipient) {
418            if ($recipient instanceof ClientSession)
419                $recipient = $recipient->getSessionUser();
420            switch (true) {
421                case $recipient instanceof EmailRecipient:
422                    $addr = sprintf('"%s" <%s>',
423                            $recipient->getName(),
424                            $recipient->getEmail());
425                    switch ($recipient->getType()) {
426                        case 'to':
427                            $mime->addTo($addr);
428                            break;
429                        case 'cc':
430                            $mime->addCc($addr);
431                            break;
432                        case 'bcc':
433                            $mime->addBcc($addr);
434                            break;
435                    }
436                    break;
437                case $recipient instanceof TicketOwner:
438                case $recipient instanceof Staff:
439                    $mime->addTo(sprintf('"%s" <%s>',
440                                $recipient->getName(),
441                                $recipient->getEmail()));
442                    break;
443                case $recipient instanceof Collaborator:
444                    $mime->addCc(sprintf('"%s" <%s>',
445                                $recipient->getName(),
446                                $recipient->getEmail()));
447                    break;
448                case $recipient instanceof EmailAddress:
449                    $mime->addTo($recipient->getAddress());
450                    break;
451                default:
452                    // Assuming email address.
453                    $mime->addTo($recipient);
454            }
455        }
456
457        // Add in extra attachments, if any from template variables
458        if ($message instanceof TextWithExtras
459            && ($attachments = $message->getAttachments())
460        ) {
461            foreach ($attachments as $a) {
462                $file = $a->getFile();
463                $mime->addAttachment($file->getData(),
464                    $file->getMimeType(), $file->getName(), false);
465            }
466        }
467
468        // If the message is not explicitly declared to be a text message,
469        // then assume that it needs html processing to create a valid text
470        // body
471        $isHtml = true;
472        if (!(isset($options['text']) && $options['text'])) {
473            // Embed the data-mid in such a way that it should be included
474            // in a response
475            if ($options['reply-tag'] || $mid_token) {
476                $message = sprintf('<div style="display:none"
477                        class="mid-%s">%s</div>%s',
478                        $mid_token,
479                        $options['reply-tag'],
480                        $message);
481            }
482
483            $txtbody = rtrim(Format::html2text($message, 90, false))
484                . ($messageId ? "\nRef-Mid: $messageId\n" : '');
485            $mime->setTXTBody($txtbody);
486        }
487        else {
488            $mime->setTXTBody($message);
489            $isHtml = false;
490        }
491
492        if ($isHtml && $cfg && $cfg->isRichTextEnabled()) {
493            // Pick a domain compatible with pear Mail_Mime
494            $matches = array();
495            if (preg_match('#(@[0-9a-zA-Z\-\.]+)#', $this->getFromAddress(), $matches)) {
496                $domain = $matches[1];
497            } else {
498                $domain = '@localhost';
499            }
500            // Format content-ids with the domain, and add the inline images
501            // to the email attachment list
502            $self = $this;
503            $message = preg_replace_callback('/cid:([\w.-]{32})/',
504                function($match) use ($domain, $mime, $self) {
505                    $file = false;
506                    foreach ($self->attachments as $id=>$F) {
507                        if ($F instanceof Attachment)
508                            $F = $F->getFile();
509                        if (strcasecmp($F->getKey(), $match[1]) === 0) {
510                            $file = $F;
511                            break;
512                        }
513                    }
514                    if (!$file)
515                        // Not attached yet attempt to attach it inline
516                        $file = AttachmentFile::lookup($match[1]);
517                    if (!$file)
518                        return $match[0];
519                    $mime->addHTMLImage($file->getData(),
520                        $file->getMimeType(), $file->getName(), false,
521                        $match[1].$domain);
522                    // Don't re-attach the image below
523                    unset($self->attachments[$file->getId()]);
524                    return $match[0].$domain;
525                }, $message);
526            // Add an HTML body
527            $mime->setHTMLBody($message);
528        }
529        //XXX: Attachments
530        if(($attachments=$this->getAttachments())) {
531            foreach($attachments as $file) {
532                // Read the filename from the Attachment if possible
533                if ($file instanceof Attachment) {
534                    $filename = $file->getFilename();
535                    $file = $file->getFile();
536                } elseif ($file instanceof AttachmentFile) {
537                    $filename = $file->getName();
538                }  elseif ($file instanceof FileObject) {
539                    $filename = $file->getFilename();
540                } else
541                    continue;
542
543                $mime->addAttachment($file->getData(),
544                    $file->getMimeType(), $filename, false);
545            }
546        }
547
548        //Desired encodings...
549        $encodings=array(
550                'head_encoding' => 'quoted-printable',
551                'text_encoding' => 'base64',
552                'html_encoding' => 'base64',
553                'html_charset'  => 'utf-8',
554                'text_charset'  => 'utf-8',
555                'head_charset'  => 'utf-8'
556                );
557        //encode the body
558        $body = $mime->get($encodings);
559        //encode the headers.
560        $headers = $mime->headers($headers, true);
561        $to = implode(',', array_filter(array($headers['To'], $headers['Cc'],
562                    $headers['Bcc'])));
563        // Cache smtp connections made during this request
564        static $smtp_connections = array();
565        if(($smtp=$this->getSMTPInfo())) { //Send via SMTP
566            $key = sprintf("%s:%s:%s", $smtp['host'], $smtp['port'],
567                $smtp['username']);
568            if (!isset($smtp_connections[$key])) {
569                $mail = mail::factory('smtp', array(
570                    'host' => $smtp['host'],
571                    'port' => $smtp['port'],
572                    'auth' => $smtp['auth'],
573                    'username' => $smtp['username'],
574                    'password' => $smtp['password'],
575                    'timeout'  => 20,
576                    'debug' => false,
577                    'persist' => true,
578                ));
579                if ($mail->connect())
580                    $smtp_connections[$key] = $mail;
581            }
582            else {
583                // Use persistent connection
584                $mail = $smtp_connections[$key];
585            }
586
587            $result = $mail->send($to, $headers, $body);
588            if(!PEAR::isError($result))
589                return $messageId;
590
591            // Force reconnect on next ->send()
592            unset($smtp_connections[$key]);
593
594            $alert=_S("Unable to email via SMTP")
595                    .sprintf(":%1\$s:%2\$d [%3\$s]\n\n%4\$s\n",
596                    $smtp['host'], $smtp['port'], $smtp['username'], $result->getMessage());
597            $this->logError($alert);
598        }
599
600        //No SMTP or it failed....use php's native mail function.
601        $args = array();
602        if (isset($options['from_address']))
603            $args[] = '-f '.$options['from_address'];
604        elseif ($this->getEmail())
605            $args = array('-f '.$this->getEmail()->getEmail());
606        $mail = mail::factory('mail', $args);
607        $to = $headers['To'];
608        $result = $mail->send($to, $headers, $body);
609        if(!PEAR::isError($result))
610            return $messageId;
611
612        $alert=_S("Unable to email via php mail function")
613                .sprintf(":%1\$s\n\n%2\$s\n",
614                $to, $result->getMessage());
615        $this->logError($alert);
616        return false;
617    }
618
619    function logError($error) {
620        global $ost;
621        //NOTE: Admin alert override - don't email when having email trouble!
622        $ost->logError(_S('Mailer Error'), $error, false);
623    }
624
625    /******* Static functions ************/
626
627    //Emails using native php mail function - if DB connection doesn't exist.
628    //Don't use this function if you can help it.
629    function sendmail($to, $subject, $message, $from, $options=null) {
630        $mailer = new Mailer(null, array('notice'=>true, 'nobounce'=>true));
631        $mailer->setFromAddress($from);
632        return $mailer->send($to, $subject, $message, $options);
633    }
634}
635?>
636