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;
}
// Anpassung Anfang: support adding files from String
function addFileArray(Array $file) {
// expected array format:
// array('data'=>$filedata,'name'=>$filename,'mimetype'=>$mimetype);
// empty mimetype will be set to 'application/octet-stream'
// add an unique ID
$file['id'] = md5($file['name']);
$this->attachments[$file['id']] = $file;
}
// Anpassung Ende: support adding files from String
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);
// Anpassung Anfang: support adding files from String
elseif (is_array($a))
$this->addFileArray($a);
// Anpassung Ende: support adding files from String
}
}
/**
* lookup Attached File by Key
*/
function getFile(String $key) {
foreach ($this->getAttachments() as $uid => $F) {
if ($F instanceof \Attachment)
$F = $F->getFile();
// Anpassung Anfang: support adding files from String
if(is_array($F) ) {
if(strcasecmp($file['id'], $key) === 0)
return $F;
else
continue;
}
// Anpassung Ende: support adding files from String
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();
// Anpassung Anfang: set encoding to utf-8 to support special chars in mail header
$message->setEncoding('utf-8');
// Anpassung Ende: set encoding to utf-8 to support special chars in mail header
// 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() . '
';
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('