osticket-docker/osTicket-v1.18.2/include/class.mailer.php

676 lines
26 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/*********************************************************************
class.mailer.php
osTicket/Mail/Mailer
Wrapper for sending emails via SMTP / SendMail
Peter Rotich <peter@osticket.com>
Copyright (c) osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
namespace osTicket\Mail;
class Mailer {
private $from = null;
var $email = null;
var $smtpAccounts = [];
var $ht = array();
var $attachments = array();
var $options = array();
var $eol="\n";
function __construct(\Email $email=null, array $options=array()) {
global $cfg;
// Get all possible outgoing emails accounts (SMTP) to try
if (($email instanceof \Email)
&& ($smtp=$email->getSmtpAccount(false))
&& $smtp->isActive()) {
$this->smtpAccounts[$smtp->getId()] = $smtp;
}
if ($cfg) {
// Get Default MTA (SMTP)
if (($smtp=$cfg->getDefaultMTA()) && $smtp->isActive()) {
$this->smtpAccounts[$smtp->getId()] = $smtp;
// If email is not set then use Default MTA
if (!$email)
$email = $smtp->getEmail();
} elseif (!$email && $cfg && ($email=$cfg->getDefaultEmail())) {
// as last resort we will send via Default Email
if (($smtp=$email->getSmtpAccount(false)) && $smtp->isActive())
$this->smtpAccounts[$smtp->getId()] = $smtp;
}
}
$this->email = $email;
$this->attachments = array();
$this->options = $options;
if (isset($this->options['eol']))
$this->eol = $this->options['eol'];
elseif (defined('MAIL_EOL') && is_string(MAIL_EOL))
$this->eol = MAIL_EOL;
}
function getEOL() {
return $this->eol;
}
function getSmtpAccounts() {
return $this->smtpAccounts;
}
function getEmail() {
return $this->email;
}
/* FROM Address */
function setFromAddress($from=null, $name=null) {
if ($from instanceof \EmailAddress)
$this->from = $from;
elseif (\Validator::is_email($from)) {
$this->from = new \EmailAddress(
str_contains($from, '<') ? $from : sprintf('"%s" <%s>', $name ?: '', $from));
} elseif (is_string($from))
$this->from = new \EmailAddress($from);
elseif (($email=$this->getEmail())) {
// we're assuming from was null or unexpected monster
$address = sprintf('"%s" <%s>',
$name ?: $email->getName(),
$email->getEmail());
$this->from = new \EmailAddress($address);
}
}
function getFromAddress($options=array()) {
if (!isset($this->from))
$this->setFromAddress(null, $options['from_name'] ?: null);
return $this->from;
}
function getFromName() {
return $this->getFromAddress()->getName();
}
function getFromEmail() {
return $this->getFromAddress()->getEmail();
}
/* attachments */
function getAttachments() {
return $this->attachments;
}
function addAttachment(\Attachment $attachment) {
$this->attachments[$attachment->getFile()->getUId()] = $attachment;
}
function addAttachmentFile(\AttachmentFile $file) {
$this->attachments[$file->getUId()] = $file;
}
function addFileObject(\FileObject $file) {
$this->attachments[$file->getUId()] = $file;
}
function addAttachments($attachments) {
foreach ($attachments as $a) {
if ($a instanceof \Attachment)
$this->addAttachment($a);
elseif ($a instanceof \AttachmentFile)
$this->addAttachmentFile($a);
elseif ($a instanceof \FileObject)
$this->addFileObject($a);
}
}
/**
* lookup Attached File by Key
*/
function getFile(String $key) {
foreach ($this->getAttachments() as $uid => $F) {
if ($F instanceof \Attachment)
$F = $F->getFile();
if (strcasecmp($F->getKey(), $key) === 0)
return $F;
}
return \AttachmentFile::lookup($key);
}
/**
* getMessageId
*
* Generates a unique message ID for an outbound message. Optionally,
* the recipient can be used to create a tag for the message ID where
* the user-id and thread-entry-id are encoded in the message-id so
* the message can be threaded if it is replied to without any other
* indicator of the thread to which it belongs. This tag is signed with
* the secret-salt of the installation to guard against false positives.
*
* Parameters:
* $recipient - (EmailContact|null) recipient of the message. The ID of
* the recipient is placed in the message id TAG section so it can
* be recovered if the email replied to directly by the end user.
* $options - (array) - options passed to ::send(). If it includes a
* 'thread' element, the threadId will be recorded in the TAG
*
* Returns:
* (string) - email message id, without leading and trailing <> chars.
* See the Format below for the structure.
*
* Format:
* VA-B-C, with dash separators and A-C explained below:
*
* V: Version code of the generated Message-Id
* A: Predictable random code — used for loop detection (sysid)
* B: Random data for unique identifier (rand)
* C: TAG: Base64(Pack(userid, entryId, threadId, type, Signature)),
* '=' chars discarded
* where Signature is:
* Signed Tag value, last 5 chars from
* HMAC(sha1, Tag + rand + sysid, SECRET_SALT),
* where Tag is:
* pack(userId, entryId, threadId, type)
*/
function getMessageId($recipient, $options=array(), $version='B') {
$tag = '';
$rand = \Misc::randCode(5,
// RFC822 specifies the LHS of the addr-spec can have any char
// except the specials — ()<>@,;:\".[], dash is reserved as the
// section separator, and + is reserved for historical reasons
'abcdefghiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_=');
$sig = $this->getEmail()?$this->getEmail()->getEmail():'@osTicketMailer';
$sysid = static::getSystemMessageIdCode();
// Create a tag for the outbound email
$entry = (isset($options['thread'])
&& ($options['thread'] instanceof \ThreadEntry))
? $options['thread'] : false;
$thread = $entry ? $entry->getThread()
: (isset($options['thread'])
&& ($options['thread'] instanceof \Thread)
? $options['thread'] : false);
switch (true) {
case $recipient instanceof \Staff:
$utype = 'S';
break;
case $recipient instanceof \TicketOwner:
$utype = 'U';
break;
case $recipient instanceof \Collaborator:
$utype = 'C';
break;
case $recipient instanceof \MailingList:
$utype = 'M';
break;
default:
$utype = ($options['utype'] ?: is_array($recipient)) ? 'M' : '?';
}
$tag = pack('VVVa',
$recipient instanceof \EmailContact ? $recipient->getUserId() : 0,
$entry ? $entry->getId() : 0,
$thread ? $thread->getId() : 0,
$utype ?: '?'
);
// Sign the tag with the system secret salt
$tag .= substr(hash_hmac('sha1', $tag.$rand.$sysid, SECRET_SALT, true), -5);
$tag = str_replace('=','',base64_encode($tag));
return sprintf('B%s-%s-%s-%s',
$sysid, $rand, $tag, $sig);
}
/**
* decodeMessageId
*
* Decodes a message-id generated by osTicket using the ::getMessageId()
* method of this class. This will digest the received message-id token
* and return an array with some information about it.
*
* Parameters:
* $mid - (string) message-id from an email Message-Id, In-Reply-To, and
* References header.
*
* Returns:
* (array) of information containing all or some of the following keys
* 'loopback' - (bool) true or false if the message originated by
* this osTicket installation.
* 'version' - (string|FALSE) version code of the message id
* 'code' - (string) unique but predictable help desk message-id
* 'id' - (string) random characters serving as the unique id
* 'entryId' - (int) thread-entry-id from which the message originated
* 'threadId' - (int) thread-id from which the message originated
* 'staffId' - (int|null) staff the email was originally sent to
* 'userId' - (int|null) user the email was originally sent to
* 'userClass' - (string) class of user the email was sent to
* 'U' - TicketOwner
* 'S' - Staff
* 'C' - Collborator
* 'M' - Multiple
* '?' - Something else
*/
static function decodeMessageId($mid) {
// Drop <> tokens
$mid = trim($mid, '<> ');
// Drop email domain on rhs
list($lhs, $sig) = explode('@', $mid, 2);
// LHS should be tokenized by '-'
$parts = explode('-', $lhs);
$rv = array('loopback' => false, 'version' => false);
// There should be at least two tokens if the message was sent by
// this system. Otherwise, there's nothing to be detected
if (count($parts) < 2)
return $rv;
$self = get_called_class();
$decoders = array(
'A' => function($id, $tag) use ($sig) {
// Old format was VA-B-C-D@sig, where C was the packed tag and D
// was blank
$format = 'Vuid/VentryId/auserClass';
$chksig = substr(hash_hmac('sha1', $tag.$id, SECRET_SALT), -10);
if ($tag && $sig == $chksig && ($tag = base64_decode($tag))) {
// Find user and ticket id
return unpack($format, $tag);
}
return false;
},
'B' => function($id, $tag) use ($self) {
$format = 'Vuid/VentryId/VthreadId/auserClass/a*sig';
if ($tag && ($tag = base64_decode($tag))) {
if (!($info = @unpack($format, $tag)) || !isset($info['sig']))
return false;
$sysid = $self::getSystemMessageIdCode();
$shorttag = substr($tag, 0, 13);
$chksig = substr(hash_hmac('sha1', $shorttag.$id.$sysid,
SECRET_SALT, true), -5);
if ($chksig == $info['sig']) {
return $info;
}
}
return false;
},
);
// Detect the MessageId version, which should be the first char
$rv['version'] = @$parts[0][0];
if (!isset($decoders[$rv['version']]))
// invalid version code
return null;
// Drop the leading version code
list($rv['code'], $rv['id'], $tag) = $parts;
$rv['code'] = substr($rv['code'], 1);
// Verify tag signature and unpack the tag
$info = $decoders[$rv['version']]($rv['id'], $tag);
if ($info === false)
return $rv;
$rv += $info;
// Attempt to make the user-id more specific
$classes = array(
'S' => 'staffId', 'U' => 'userId', 'C' => 'userId',
);
if (isset($classes[$rv['userClass']]))
$rv[$classes[$rv['userClass']]] = $rv['uid'];
// Round-trip detection - the first section is the local
// system's message-id code
$rv['loopback'] = (0 === strcmp($rv['code'],
static::getSystemMessageIdCode()));
return $rv;
}
static function getSystemMessageIdCode() {
return substr(str_replace('+', '=',
base64_encode(md5('mail'.SECRET_SALT, true))),
0, 6);
}
function send($recipients, $subject, $body, $options=null) {
global $ost, $cfg;
$messageId = $this->getMessageId($recipients, $options);
$subject = preg_replace("/(\r\n|\r|\n)/s",'', trim($subject));
$from = $this->getFromAddress($options);
// Create new ostTicket/Mail/Message object
$message = new Message();
// Set our custom Message-Id
$message->setMessageId($messageId);
// Set From Address
$message->setFrom($from->getEmail(), $from->getName());
// Set Subject
$message->setSubject($subject);
// Collect Generic Headers
$headers = ['X-Mailer' =>'osTicket Mailer'];
// Add in the options passed to the constructor
$options = ($options ?: array()) + $this->options;
// Message Id Token
$mid_token = '';
// Check if the email is threadable
if (isset($options['thread'])
&& ($options['thread'] instanceof \ThreadEntry)
&& ($thread = $options['thread']->getThread())) {
// Add email in-reply-to references if not set
if (!isset($options['inreplyto'])) {
$entry = null;
switch (true) {
case $recipients instanceof \MailingList:
$entry = $thread->getLastEmailMessage();
break;
case $recipients instanceof \TicketOwner:
case $recipients instanceof \Collaborator:
$entry = $thread->getLastEmailMessage(array(
'user_id' => $recipients->getUserId()));
break;
case $recipients instanceof \Staff:
//XXX: is it necessary ??
break;
}
if ($entry && ($mid=$entry->getEmailMessageId())) {
$options['inreplyto'] = $mid;
$options['references'] = $entry->getEmailReferences();
}
}
// Embedded message id token
$mid_token = $messageId;
// Set Reply-Tag
if (!isset($options['reply-tag'])) {
if ($cfg && $cfg->stripQuotedReply())
$options['reply-tag'] = $cfg->getReplySeparator() . '<br/><br/>';
else
$options['reply-tag'] = '';
} elseif ($options['reply-tag'] === false) {
$options['reply-tag'] = '';
}
}
// Return-Path
if (isset($options['nobounce']) && $options['nobounce'])
$message->setReturnPath('<>');
elseif ($this->getEmail() instanceof \Email)
$message->setReturnPath($this->getEmail()->getEmail());
// Bulk.
if (isset($options['bulk']) && $options['bulk'])
$headers+= array('Precedence' => 'bulk');
// Auto-reply - mark as autoreply and supress all auto-replies
if (isset($options['autoreply']) && $options['autoreply']) {
$headers+= array(
'Precedence' => 'auto_reply',
'X-Autoreply' => 'yes',
'X-Auto-Response-Suppress' => 'DR, RN, OOF, AutoReply',
'Auto-Submitted' => 'auto-replied');
}
// Notice (sort of automated - but we don't want auto-replies back
if (isset($options['notice']) && $options['notice'])
$headers+= array(
'X-Auto-Response-Suppress' => 'OOF, AutoReply',
'Auto-Submitted' => 'auto-generated');
// In-Reply-To
if (isset($options['inreplyto']) && $options['inreplyto'])
$message->addInReplyTo($options['inreplyto']);
// References
if (isset($options['references']) && $options['references'])
$message->addReferences($options['references']);
// Add Headers
$message->addHeaders($headers);
// Add recipients
if (!is_array($recipients) && (!$recipients instanceof \MailingList))
$recipients = array($recipients);
foreach ($recipients as $recipient) {
if ($recipient instanceof \ClientSession)
$recipient = $recipient->getSessionUser();
try {
switch (true) {
case $recipient instanceof \EmailRecipient:
$email = (string) $recipient->getEmail()->getEmail();
$name = (string) $recipient->getName();
switch ($recipient->getType()) {
case 'to':
$message->addTo($email, $name);
break;
case 'cc':
$message->addCc($email, $name);
break;
case 'bcc':
$message->addBcc($email, $name);
break;
}
break;
case $recipient instanceof \TicketOwner:
case $recipient instanceof \Staff:
$message->addTo((string) $recipient->getEmail(),
(string) $recipient->getName());
break;
case $recipient instanceof \Collaborator:
$message->addCc((string) $recipient->getEmail(),
(string) $recipient->getName());
break;
case $recipient instanceof \EmailAddress:
$message->addTo((string) $recipient->getEmail(),
(string) $recipient->getName());
break;
default:
// Assuming email address.
if (is_string($recipient))
$message->addTo($recipient);
}
} catch(\Exception $ex) {
$this->logWarning(sprintf("%s1\$s: %2\$s\n\n%3\$s\n",
_S("Unable to add email recipient"),
($recipient instanceof EmailContact)
? $recipient->getEmailAddress()
: (string) $recipient,
$ex->getMessage()
));
}
}
// Add in extra attachments, if any from template variables
if ($body instanceof \TextWithExtras
&& ($attachments = $body->getAttachments())) {
foreach ($attachments as $a) {
$message->addAttachment($a->getFile());
}
}
// If the message is not explicitly declared to be a text message,
// then assume that it needs html processing to create a valid text
// body
$isHtml = true;
if (!(isset($options['text']) && $options['text'])) {
// Embed the data-mid in such a way that it should be included
// in a response
if ($options['reply-tag'] || $mid_token) {
$body = sprintf('<div style="display:none"
class="mid-%s">%s</div>%s',
$mid_token,
$options['reply-tag'],
$body);
}
$txtbody = rtrim(\Format::html2text($body, 90, false))
. ($messageId ? "\nRef-Mid: $messageId\n" : '');
$message->setTextBody($txtbody);
}
else {
$message->setTextBody($body);
$isHtml = false;
}
if ($isHtml && $cfg && $cfg->isRichTextEnabled()) {
// Pick a domain compatible with pear Mail_Mime
$matches = array();
if (preg_match('#(@[0-9a-zA-Z\-\.]+)#', (string) $this->getFromAddress(), $matches)) {
$domain = $matches[1];
} else {
$domain = '@localhost';
}
// Format content-ids with the domain, and add the inline images
// to the email attachment list
$self = $this;
$body = preg_replace_callback('/cid:([\w.-]{32})/',
function($match) use ($domain, $message, $self) {
if (!($file=$self->getFile($match[1])))
return $match[0];
try {
$message->addInlineImage($match[1].$domain, $file);
// Don't re-attach the image below
unset($self->attachments[$file->getUId()]);
return $match[0].$domain;
} catch(\Exception $ex) {
$self->logWarning(sprintf("%1\$s:%2\$s\n\n%3\$s\n",
_S("Unable to retrieve email inline image"),
$match[1].$domain,
$ex->getMessage()));
}
}, $body);
// Add an HTML body
$message->setHtmlBody($body);
}
//XXX: Attachments
if(($attachments=$this->getAttachments())) {
foreach($attachments as $file) {
if ($file instanceof \Attachment) {
$filename = $file->getFilename();
$file = $file->getFile();
} else {
$filename = $file->getName();
}
try {
$message->addAttachment($file, $filename);
} catch(\Exception $ex) {
$this->logWarning(sprintf("%1\$s:%2\$s\n\n%3\$s\n",
_S("Unable to retrieve email attachment"),
$filename,
$ex->getMessage()));
}
}
}
// Try possible SMTP Accounts - connections are cached per request
// at the account level.
foreach ($this->getSmtpAccounts() ?: [] as $smtpAccount) {
try {
// Check if SPOOFING is allowed.
if (!$smtpAccount->allowSpoofing()
&& strcasecmp($smtpAccount->getEmail()->getEmail(),
$this->getFromEmail())) {
// If the Account DOES NOT allow spoofing then set the
// Sender as the smtp account sending out the email
// TODO: Allow Aliases to Spoof parent account by
// default.
$message->setSender(
// get Account Email
(string) $smtpAccount->getEmail()->getEmail(),
// Try to keep the name if available
$this->getFromName() ?: $smtpAccount->getName() ?: $this->getEmail());
}
// Attempt to send the Message.
if (($smtp=$smtpAccount->getSmtpConnection())
&& $smtp->sendMessage($message))
return $message->getId();
} catch (\Exception $ex) {
// Log the SMTP error
$this->logError(sprintf("%1\$s: %2\$s (%3\$s)\n\n%4\$s\n",
_S("Unable to email via SMTP"),
$smtpAccount->getEmail()->getEmail(),
$smtpAccount->getHostInfo(),
$ex->getMessage()
));
}
// Attempt Failed: Reset FROM to original email and clear Sender
$message->setOriginator($this->getFromEmail(), $this->getFromName());
}
// No SMTP or it FAILED....use Sendmail transport (PHP mail())
// Set Sender / Originator
if (isset($options['from_address'])) {
// This is often set via Mailer::sendmail()
$message->setSender($options['from_address']);
} elseif (($from=$this->getFromAddress())) {
// This should be already set but we're making doubly sure
$message->setOriginator($from->getEmail(), $from->getName());
}
try {
// ostTicket/Mail/Sendmail transport (mail())
// Set extra params as needed
// NOTE: Laminas Mail doesn't respect -f param set Sender (above)
$args = [];
$sendmail = new Sendmail($args);
if ($sendmail->sendMessage($message))
return $message->getId();
} catch (\Exception $ex) {
$this->logError(sprintf("%1\$s\n\n%2\$s\n",
_S("Unable to email via Sendmail"),
$ex->getMessage()
));
}
return false;
}
function log($msg, $type='warning') {
global $ost;
if (!$ost || !$msg)
return;
// No email alerts on email errors
switch ($type) {
case 'error':
return $ost->logError(_S('Mailer Error'), $msg, false);
break;
case 'warning':
default:
return $ost->logWarning(_S('Mailer Warning'), $msg, false);
}
return false;
}
function logError($error) {
return $this->log($error, 'error');
}
function logWarning($warning) {
return $this->log($warning, 'warning');
}
//Emails using native php mail function - if DB connection doesn't exist.
//Don't use this function if you can help it.
static function sendmail($to, $subject, $message, $from=null, $options=null) {
$mailer = new Mailer(null, array('notice'=>true, 'nobounce'=>true));
$mailer->setFromAddress($from, $options['from_name'] ?: null);
return $mailer->send($to, $subject, $message, $options);
}
}