Newer
Older
<?php
/*********************************************************************
class.thread.php
XXX: Please DO NOT add any ticket related logic! use ticket class.
Peter Rotich <peter@osticket.com>
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:
**********************************************************************/
include_once(INCLUDE_DIR.'class.ticket.php');
include_once(INCLUDE_DIR.'class.draft.php');
//Ticket thread.
class Thread extends VerySimpleModel {
static $meta = array(
'table' => THREAD_TABLE,
'pk' => array('id'),
'joins' => array(
'ticket' => array(
'constraint' => array(
'object_id' => 'TicketModel.ticket_id',
'object_type' => "'T'",
),
),
'entries' => array(
'reverse' => 'ThreadEntry.thread',
),
),
);
var $_object;
return $this->id;
return $this->object_id;
return $this->object_type;
function getObject() {
if (!$this->_object)
$this->_object = ObjectModel::lookup(
$this->getObjectId(), $this->getObjectType());
return $this->_object;
}
function getNumAttachments() {
return Attachment::objects()->filter(array(
'thread_entry__thread' => $this
))->count();
return $this->entries->count();
}
function getEntries($criteria=false) {
$base = $this->entries->annotate(array(
'has_attachments' => SqlAggregate::COUNT('attachments')
));
if ($criteria)
$base->filter($criteria);
return $base;
/**
* postEmail
*
* After some security and sanity checks, attaches the body and subject
* of the message in reply to this thread item
*
* Parameters:
* mailinfo - (array) of information about the email, with at least the
* following keys
* - mid - (string) email message-id
* - name - (string) personal name of email originator
* - email - (string<email>) originating email address
* - subject - (string) email subject line (decoded)
* - body - (string) email message body (decoded)
*/
function postEmail($mailinfo) {
global $ost;
// +==================+===================+=============+
// | Orig Thread-Type | Reply Thread-Type | Requires |
// +==================+===================+=============+
// | * | Message (M) | From: Owner |
// | * | Note (N) | From: Staff |
// | Response (R) | Message (M) | |
// | Message (M) | Response (R) | From: Staff |
// +------------------+-------------------+-------------+
if (!$object = $this->getObject()) {
// How should someone find this thread?
return false;
}
elseif ($object instanceof Ticket && (
!$mailinfo['staffId']
&& $object->isClosed()
&& !$object->isReopenable()
)) {
// Ticket is closed, not reopenable, and email was not submitted
// by an agent. Email cannot be submitted
return false;
}
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
// Mail sent by this system will have a message-id format of
// <code-random-mailbox@domain.tld>
// where code is a predictable string based on the SECRET_SALT of
// this osTicket installation. If this incoming mail matches the
// code, then it very likely originated from this system and looped
@list($code) = explode('-', $mailinfo['mid'], 2);
if (0 === strcasecmp(ltrim($code, '<'), substr(md5('mail'.SECRET_SALT), -9))) {
// This mail was sent by this system. It was received due to
// some kind of mail delivery loop. It should not be considered
// a response to an existing thread entry
if ($ost) $ost->log(LOG_ERR, _S('Email loop detected'), sprintf(
_S('It appears as though <%s> is being used as a forwarded or fetched email account and is also being used as a user / system account. Please correct the loop or seek technical assistance.'),
$mailinfo['email']),
// This is quite intentional -- don't continue the loop
false,
// Force the message, even if logging is disabled
true);
return true;
}
$vars = array(
'mid' => $mailinfo['mid'],
'header' => $mailinfo['header'],
'poster' => $mailinfo['name'],
'origin' => 'Email',
'source' => 'Email',
'ip' => '',
'reply_to' => $this,
'recipients' => $mailinfo['recipients'],
'to-email-id' => $mailinfo['to-email-id'],
);
// XXX: Is this necessary?
if ($object instanceof Ticket)
$vars['ticketId'] = $object->getId();
if ($object instanceof Task)
$vars['taskId'] = $object->getId();
$errors = array();
if (isset($mailinfo['attachments']))
$vars['attachments'] = $mailinfo['attachments'];
$body = $mailinfo['message'];
$poster = $mailinfo['email'];
// Disambiguate if the user happens also to be a staff member of the
// system. The current ticket owner should _always_ post messages
// instead of notes or responses
if ($mailinfo['userId'] || (
$object instanceof Ticket
&& strcasecmp($mailinfo['email'], $object->getEmail()) == 0
)) {
$vars['message'] = $body;
$vars['userId'] = $mailinfo['userId'] ?: $object->getUserId();
$vars['origin'] = 'Email';
if ($object instanceof Threadable)
return $object->postThreadEntry('M', $vars);
elseif ($this instanceof ObjectThread)
$this->addMessage($vars, $errors);
else
throw new Exception('Cannot continue discussion with abstract thread');
}
// XXX: Consider collaborator role
elseif ($mailinfo['staffId']
|| ($mailinfo['staffId'] = Staff::getIdByEmail($mailinfo['email']))) {
$vars['staffId'] = $mailinfo['staffId'];
$vars['poster'] = Staff::lookup($mailinfo['staffId']);
$vars['note'] = $body;
if ($object instanceof Threadable)
return $object->postThreadEntry('N', $vars);
elseif ($this instanceof ObjectThread)
return $this->addNote($vars, $errors);
else
throw new Exception('Cannot continue discussion with abstract thread');
}
elseif (Email::getIdByEmail($mailinfo['email'])) {
// Don't process the email -- it came FROM this system
return true;
}
// Support the mail parsing system declaring a thread-type
elseif (isset($mailinfo['thread-type'])) {
switch ($mailinfo['thread-type']) {
case 'N':
$vars['note'] = $body;
$vars['poster'] = $poster;
if ($object instanceof Threadable)
return $object->postThreadEntry('N', $vars);
elseif ($this instanceof ObjectThread)
return $this->addNote($vars, $errors);
else
throw new Exception('Cannot continue discussion with abstract thread');
}
}
// TODO: Consider security constraints
else {
//XXX: Are we potentially leaking the email address to
// collaborators?
// Try not to destroy the format of the body
$body->prepend(sprintf('Received From: %s', $mailinfo['email']));
$vars['message'] = $body;
$vars['userId'] = 0; //Unknown user! //XXX: Assume ticket owner?
$vars['origin'] = 'Email';
if ($object instanceof Threadable)
return $object->postThreadEntry('M', $vars);
elseif ($this instanceof ObjectThread)
return $this->addMessage($vars, $errors);
else
throw new Exception('Cannot continue discussion with abstract thread');
}
// Currently impossible, but indicate that this thread object could
// not append the incoming email.
return false;
}
$deleted = Attachment::objects()->filter(array(
'thread_entry__thread' => $this,
))->delete();
if ($deleted)
function removeCollaborators() {
$sql='DELETE FROM '.TICKET_COLLABORATOR_TABLE
.' WHERE ticket_id='.db_input($this->getObjectId());
return (db_query($sql) && db_affected_rows());
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
/**
* Function: lookupByEmailHeaders
*
* Attempt to locate a thread by the email headers. It should be
* considered a secondary lookup to ThreadEntry::lookupByEmailHeaders(),
* which should find an actual thread entry, which should be possible
* for all email communcation which is associated with a thread entry.
* The only time where this is useful is for threads which triggered
* email communication without a thread entry, for instance, like
* tickets created without an initial message.
*/
function lookupByEmailHeaders(&$mailinfo) {
$possibles = array();
foreach (array('in-reply-to', 'references') as $header) {
$matches = array();
if (!isset($mailinfo[$header]) || !$mailinfo[$header])
continue;
// Header may have multiple entries (usually separated by
// spaces ( )
elseif (!preg_match_all('/<[^>@]+@[^>]+>/', $mailinfo[$header],
$matches))
continue;
// The References header will have the most recent message-id
// (parent) on the far right.
// @see rfc 1036, section 2.2.5
// @see http://www.jwz.org/doc/threading.html
$possibles = array_merge($possibles, array_reverse($matches[0]));
}
// Add the message id if it is embedded in the body
$match = array();
if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`',
$mailinfo['message'], $match)
&& !in_array($match[1], $possibles)
) {
$possibles[] = $match[1];
}
foreach ($possibles as $mid) {
// Attempt to detect the ticket and user ids from the
// message-id header. If the message originated from
// osTicket, the Mailer class can break it apart. If it came
// from this help desk, the 'loopback' property will be set
// to true.
$mid_info = Mailer::decodeMessageId($mid);
if ($mid_info['loopback'] && isset($mid_info['uid'])
&& @$mid_info['threadId']
&& ($t = Thread::lookup($mid_info['threadId']))
) {
if (@$mid_info['userId']) {
$mailinfo['userId'] = $mid_info['userId'];
}
elseif (@$mid_info['staffId']) {
$mailinfo['staffId'] = $mid_info['staffId'];
}
// ThreadEntry was positively identified
return $t;
}
}
return null;
}
if (!parent::delete())
ThreadEntryEmailInfo::objects()
->filter(array('thread_entry__thread' => $this))
->update(array('headers' => null));
$this->removeCollaborators();
$this->entries->delete();
$inst = parent::create($vars);
$inst->created = SqlFunction::NOW();
return $inst;
class ThreadEntryEmailInfo extends VerySimpleModel {
static $meta = array(
'table' => THREAD_ENTRY_EMAIL_TABLE,
'pk' => array('id'),
'joins' => array(
'thread_entry' => array(
'constraint' => array('thread_entry_id' => 'ThreadEntry.id'),
),
),
);
class ThreadEntry extends VerySimpleModel {
static $meta = array(
'table' => THREAD_ENTRY_TABLE,
'pk' => array('id'),
'select_related' => array('staff', 'user', 'email_info'),
'joins' => array(
'thread' => array(
'constraint' => array('thread_id' => 'Thread.id'),
'parent' => array(
'constraint' => array('pid' => 'ThreadEntry.id'),
'null' => true,
),
'children' => array(
'reverse' => 'ThreadEntry.parent',
),
'email_info' => array(
'reverse' => 'ThreadEntryEmailInfo.thread_entry',
'list' => false,
),
'attachments' => array(
'reverse' => 'Attachment.thread_entry',
'null' => true,
),
'staff' => array(
'constraint' => array('staff_id' => 'Staff.staff_id'),
'null' => true,
),
'user' => array(
'constraint' => array('user_id' => 'User.id'),
'null' => true,
),
),
);
var $_headers;
var $_thread;
var $_attachments;
function postEmail($mailinfo) {
if (!($thread = $this->getThread()))
// Kind of hard to continue a discussion without a thread ...
return false;
elseif ($this->getEmailMessageId() == $mailinfo['mid'])
// Reporting success so the email can be moved or deleted.
return true;
return $thread->postEmail($mailinfo);
}
function getId() {
return $this->id;
}
function getPid() {
return $this->pid;
function getParent() {
if ($this->getPid())
return ThreadEntry::lookup($this->getPid());
}
function getType() {
return $this->type;
}
function getSource() {
return $this->source;
}
function getPoster() {
return $this->poster;
}
function getTitle() {
return $this->title;
}
function getBody() {
return ThreadEntryBody::fromFormattedText($this->body, $this->format);
function setBody($body) {
global $cfg;
if ($cfg->isHtmlThreadEnabled())
$this->format = $body->getType();
$this->body = (string) $body;
return $this->save();
function getCreateDate() {
return $this->created;
}
function getUpdateDate() {
return $this->updated;
}
function getNumAttachments() {
return $this->attachments->count();
function getEmailMessageId() {
if ($this->email_info)
return $this->email_info->mid;
function getEmailHeaderArray() {
require_once(INCLUDE_DIR.'class.mailparse.php');
if (!isset($this->_headers) && $this->email_info
&& isset($this->email_info->headers)
) {
$this->_headers = Mail_Parse::splitHeaders($this->email_info->headers);
}
return $this->_headers;
function getEmailReferences($include_mid=true) {
$references = '';
$headers = self::getEmailHeaderArray();
if (isset($headers['References']) && $headers['References'])
$references = $headers['References']." ";
if ($include_mid && ($mid = $this->getEmailMessageId()))
$references .= $mid;
function getUIDFromEmailReference($ref) {
$info = unpack('Vtid/Vuid',
Base32::decode(strtolower(substr($ref, -13))));
if ($info && $info['tid'] == $this->getId())
return $info['uid'];
}
return $this->thread_id;
if (!isset($this->_thread) && $this->thread_id)
// TODO: Consider typing the thread based on its type field
$this->_thread = ObjectThread::lookup($this->getThreadId());
return $this->_thread;
}
function getStaffId() {
return isset($this->staff_id) ? $this->staff_id : 0;
}
function getStaff() {
return $this->staff;
}
function getUserId() {
return isset($this->user_id) ? $this->user_id : 0;
}
function getUser() {
return $this->user;
}
function getName() {
if ($this->staff_id)
return $this->staff->getName();
if ($this->user_id)
return $this->user->getName();
return $this->poster;
if ($this->email_info)
return $this->email_info->headers;
function isAutoReply() {
if (!isset($this->is_autoreply))
$this->is_autoreply = $this->getEmailHeaderArray()
? TicketFilter::isAutoReply($this->getEmailHeaderArray()) : false;
return $this->is_autoreply;
function isBounce() {
if (!isset($this->is_bounce))
$this->is_bounce = $this->getEmailHeaderArray()
? TicketFilter::isBounce($this->getEmailHeaderArray()) : false;
return $this->is_bounce;
function isBounceOrAutoReply() {
return ($this->isAutoReply() || $this->isBounce());
}
//Web uploads - caller is expected to format, validate and set any errors.
function uploadFiles($files) {
if(!$files || !is_array($files))
return false;
$uploaded=array();
foreach($files as $file) {
if($file['error'] && $file['error']==UPLOAD_ERR_NO_FILE)
continue;
if(!$file['error']
&& ($F=AttachmentFile::upload($file))
&& $this->saveAttachment($F))
$uploaded[]=$id;
else {
if(!$file['error'])
$error = sprintf(__('Unable to upload file - %s'),$file['name']);
elseif(is_numeric($file['error']))
$error ='Error #'.$file['error']; //TODO: Transplate to string.
else
$error = $file['error'];
/*
Log the error as an internal note.
XXX: We're doing it here because it will eventually become a thread post comment (hint: comments coming!)
XXX: logNote must watch for possible loops
*/
$this->getThread()->getObject()->logNote(__('File Upload Error'), $error, 'SYSTEM', false);
function importAttachments(&$attachments) {
if(!$attachments || !is_array($attachments))
return null;
$files = array();
foreach($attachments as &$attachment)
if(($id=$this->importAttachment($attachment)))
$files[] = $id;
return $files;
}
/* Emailed & API attachments handler */
function importAttachment(&$attachment) {
if(!$attachment || !is_array($attachment))
return null;
$A=null;
if ($attachment['error'] || !($A=$this->saveAttachment($attachment))) {
$error = sprintf(_S('Unable to import attachment - %s'),
$attachment['name']);
$this->getThread()->getObject()->logNote(
_S('File Import Error'), $error, _S('SYSTEM'), false);
return $A;
}
/*
Save attachment to the DB.
@file is a mixed var - can be ID or file hashtable.
*/
$inline = is_array($file) && @$file['inline'];
if (is_numeric($file))
$fileId = $file;
elseif ($file instanceof AttachmentFile)
$fileId = $file->getId();
elseif ($F = AttachmentFile::create($file))
$fileId = $F->getId();
elseif (is_array($file) && isset($file['id']))
$fileId = $file['id'];
else
return false;
$att = Attachment::create(array(
'type' => 'H',
'object_id' => $this->getId(),
'file_id' => $fileId,
'inline' => $inline ? 1 : 0,
));
if (!$att->save())
return false;
return $att;
$attachments = array();
if (($A = $this->saveAttachment($file)))
$attachments[] = $A;
return $attachments;
return $this->attachments;
foreach ($this->attachments as $att) {
$json[$att->file->getKey()] = array(
'download_url' => $att->file->getDownloadUrl(),
'filename' => $att->file->name,
return $json;
}
function getAttachmentsLinks($file='attachment.php', $target='_blank', $separator=' ') {
// TODO: Move this to the respective UI templates
foreach ($this->attachments as $att ) {
if ($att->inline) continue;
if ($att->file->size)
$size=sprintf('<em>(%s)</em>', Format::file_size($att->file->size));
$str .= sprintf(
'<a class="Icon file no-pjax" href="%s" target="%s">%s</a>%s %s',
$att->file->getDownloadUrl(), $target,
Format::htmlchars($att->file->name), $size, $separator);
}
return $str;
}
/* Returns file names with id as key */
function getFiles() {
$files = array();
foreach($this->attachments as $attachment)
$files[$attachment->file_id] = $attachment->file->name;
return $files;
}
/* save email info
* TODO: Refactor it to include outgoing emails on responses.
*/
function saveEmailInfo($vars) {
if(!$vars || !$vars['mid'])
return 0;
$this->ht['email_mid'] = $vars['mid'];
$header = false;
if (isset($vars['header']))
$header = $vars['header'];
self::logEmailHeaders($this->getId(), $vars['mid'], $header);
/* static */
function logEmailHeaders($id, $mid, $header=false) {
$this->email_info = ThreadEntryEmailInfo::create(array(
'thread_entry_id' => $id,
'mid' => $mid,
));
$this->email_info->headers = trim($header);
return $this->email_info->save();
/* variables */
function __toString() {
return (string) $this->getBody();
return (string) $this->getBody()->display('email');
function getVar($tag) {
global $cfg;
if($tag && is_callable(array($this, 'get'.ucfirst($tag))))
return call_user_func(array($this, 'get'.ucfirst($tag)));
switch(strtolower($tag)) {
case 'create_date':
// XXX: Consider preferences of receiving user
return Format::datetime($this->getCreateDate(), true, 'UTC');
case 'update_date':
return Format::datetime($this->getUpdateDate(), true, 'UTC');
}
return false;
}
/**
* Parameters:
* mailinfo (hash<String>) email header information. Must include keys
* - "mid" => Message-Id header of incoming mail
* - "in-reply-to" => Message-Id the email is a direct response to
* - "references" => List of Message-Id's the email is in response
* - "subject" => Find external ticket number in the subject line
*
* seen (by-ref:bool) a flag that will be set if the message-id was
* positively found, indicating that the message-id has been
* previously seen. This is useful if no thread-id is associated
* with the email (if it was rejected for instance).
function lookupByEmailHeaders(&$mailinfo, &$seen=false) {
// Search for messages using the References header, then the
// in-reply-to header
if ($entry = ThreadEntry::objects()
->filter(array('email_info__mid' => $mailinfo['mid']))
->first()
) {
return $entry;
foreach (array('in-reply-to', 'references') as $header) {
$matches = array();
if (!isset($mailinfo[$header]) || !$mailinfo[$header])
continue;
// Header may have multiple entries (usually separated by
elseif (!preg_match_all('/<[^>@]+@[^>]+>/', $mailinfo[$header],
$matches))
continue;
// The References header will have the most recent message-id
// (parent) on the far right.
// @see rfc 1036, section 2.2.5
// @see http://www.jwz.org/doc/threading.html
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
$possibles = array_merge($possibles, array_reverse($matches[0]));
}
// Add the message id if it is embedded in the body
$match = array();
if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`',
$mailinfo['message'], $match)
&& !in_array($match[1], $possibles)
) {
$possibles[] = $match[1];
}
$thread = null;
foreach ($possibles as $mid) {
//Try to determine if it's a reply to a tagged email.
$ref = null;
if (strpos($mid, '+')) {
list($left, $right) = explode('@',$mid);
list($left, $ref) = explode('+', $left);
$mid = "$left@$right";
}
$entries = ThreadEntry::objects()
->filter(array('email_info__mid' => $mid));
foreach ($entries as $t) {
// Capture the first match thread item
if (!$thread)
$thread = $t;
// We found a match - see if we can ID the user.
// XXX: Check access of ref is enough?
if ($ref && ($uid = $t->getUIDFromEmailReference($ref))) {
if ($ref[0] =='s') //staff
$mailinfo['staffId'] = $uid;
else // user or collaborator.
$mailinfo['userId'] = $uid;
// Best possible case — found the thread and the
// user
return $t;
}
// Attempt to detect the ticket and user ids from the
// message-id header. If the message originated from
// osTicket, the Mailer class can break it apart. If it came
// from this help desk, the 'loopback' property will be set
// to true.
$mid_info = Mailer::decodeMessageId($mid);
if ($mid_info['loopback'] && isset($mid_info['uid'])
&& @$mid_info['entryId']
&& ($t = ThreadEntry::lookup($mid_info['entryId']))
&& ($t->thread_id == $mid_info['threadId'])
) {
if (@$mid_info['userId']) {
$mailinfo['userId'] = $mid_info['userId'];
elseif (@$mid_info['staffId']) {
$mailinfo['staffId'] = $mid_info['staffId'];
// ThreadEntry was positively identified
return $t;
// Second best case — found a thread but couldn't identify the
// user from the header. Return the first thread entry matched
if ($thread)
return $thread;
// Search for ticket by the [#123456] in the subject line
// This is the last resort - emails must match to avoid message
// injection by third-party.
$subject = $mailinfo['subject'];
$match = array();
if ($subject
&& $mailinfo['email']
// Required `#` followed by one or more of
// punctuation (-) then letters, numbers, and symbols
// (Try not to match closing punctuation (`]`) in [#12345])
&& preg_match("/#((\p{P}*[^\p{C}\p{Z}\p{P}]+)+)/u", $subject, $match)
//Lookup by ticket number
&& ($ticket = Ticket::lookupByNumber($match[1]))
//Lookup the user using the email address
&& ($user = User::lookup(array('emails__address' => $mailinfo['email'])))) {
//We have a valid ticket and user
if ($ticket->getUserId() == $user->getId() //owner
|| ($c = Collaborator::lookup( // check if collaborator
array('userId' => $user->getId(),
'ticketId' => $ticket->getId())))) {
$mailinfo['userId'] = $user->getId();
return $ticket->getLastMessage();
}
}
// Search for the message-id token in the body
// *DEPRECATED* the current algo on outgoing mail will use
// Mailer::getMessageId as the message id tagged here
if (preg_match('`(?:data-mid="|Ref-Mid: )([^"\s]*)(?:$|")`',
$mailinfo['message'], $match))
if ($thread = ThreadEntry::lookupByRefMessageId($match[1],
$mailinfo['email']))
return $thread;
/**
* Find a thread entry from a message-id created from the
* ::asMessageId() method.
*
* *DEPRECATED* use Mailer::decodeMessageId() instead
function lookupByRefMessageId($mid, $from) {
$mid = trim($mid, '<>');
list($ver, $ids, $mails) = explode('$', $mid, 3);
// Current version is <null>
if ($ver !== '')
return false;
$ids = @unpack('Vthread', base64_decode($ids));
if (!$ids || !$ids['thread'])
return false;
$thread = ThreadEntry::lookup($ids['thread']);
if (!$thread)
return false;
//new entry ... we're trusting the caller to check validity of the data.
if ($cfg->isHtmlThreadEnabled())
$vars['body'] = new HtmlThreadEntryBody($vars['body']);
else
$vars['body'] = new TextThreadEntryBody($vars['body']);
foreach ($vars['body']->getStrippedImages() as $cid) {
foreach ($vars['attachments'] as $i=>$a) {
if (@$a['cid'] && $a['cid'] == $cid) {
// Inline referenced attachment was stripped
unset($vars['attachments'][$i]);
// Handle extracted embedded images (<img src="data:base64,..." />).
// The extraction has already been performed in the ThreadEntryBody
// class. Here they should simply be added to the attachments list
if ($atts = $vars['body']->getEmbeddedHtmlImages()) {
if (!is_array($vars['attachments']))
$vars['attachments'] = array();
foreach ($atts as $info) {
$vars['attachments'][] = $info;
}
}
if (!($body = $vars['body']->getClean()))
$body = '-'; //Special tag used to signify empty message as stored.