freescout/freescout-dist/app/Thread.php

1542 lines
48 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App;
use App\SendLog;
use App\Events\ConversationStatusChanged;
use App\Events\ConversationUserChanged;
use App\Events\UserAddedNote;
use App\Events\CustomerCreatedConversation;
use App\Events\UserCreatedConversation;
use App\Events\UserCreatedConversationDraft;
use App\Events\UserCreatedThreadDraft;
use App\Events\UserReplied;
use App\Events\CustomerReplied;
use Illuminate\Database\Eloquent\Model;
class Thread extends Model
{
/**
* By whom action performed (source_via).
*/
const PERSON_CUSTOMER = 1;
const PERSON_USER = 2;
public static $persons = [
self::PERSON_CUSTOMER => 'customer',
self::PERSON_USER => 'user',
];
/**
* Thread types.
*/
// Email from customer
const TYPE_CUSTOMER = 1;
// Thead created by user
const TYPE_MESSAGE = 2;
const TYPE_NOTE = 3;
// Thread status change
const TYPE_LINEITEM = 4;
//const TYPE_PHONE = 5;
// Forwarded threads - used in API only.
//const TYPE_FORWARDPARENT = 6;
//const TYPE_FORWARDCHILD = 7;
const TYPE_CHAT = 8;
public static $types = [
// Thread by customer
self::TYPE_CUSTOMER => 'customer',
// Thread by user
self::TYPE_MESSAGE => 'message',
self::TYPE_NOTE => 'note',
// lineitem represents a change of state on the conversation. This could include, but not limited to, the conversation was assigned, the status changed, the conversation was moved from one mailbox to another, etc. A line item wont have a body, to/cc/bcc lists, or attachments.
self::TYPE_LINEITEM => 'lineitem',
//self::TYPE_PHONE => 'phone',
// When a conversation is forwarded, a new conversation is created to represent the forwarded conversation.
// forwardparent is the type set on the thread of the original conversation that initiated the forward event.
//self::TYPE_FORWARDPARENT => 'forwardparent',
// forwardchild is the type set on the first thread of the new forwarded conversation.
//self::TYPE_FORWARDCHILD => 'forwardchild',
// Not used.
self::TYPE_CHAT => 'chat',
];
/**
* Subtypes (for notes mostly)
*/
const SUBTYPE_FORWARD = 1;
const SUBTYPE_PHONE = 2;
/**
* Statuses (code must be equal to conversations statuses).
*/
const STATUS_ACTIVE = 1;
const STATUS_PENDING = 2;
const STATUS_CLOSED = 3;
const STATUS_SPAM = 4;
const STATUS_NOCHANGE = 6;
public static $statuses = [
self::STATUS_ACTIVE => 'active',
self::STATUS_CLOSED => 'closed',
self::STATUS_NOCHANGE => 'nochange',
self::STATUS_PENDING => 'pending',
self::STATUS_SPAM => 'spam',
];
/**
* States.
*/
const STATE_DRAFT = 1;
const STATE_PUBLISHED = 2;
const STATE_HIDDEN = 3;
// A state of review means the thread has been stopped by Traffic Cop and is waiting
// to be confirmed (or discarded) by the person that created the thread.
const STATE_REVIEW = 4;
public static $states = [
self::STATE_DRAFT => 'draft',
self::STATE_PUBLISHED => 'published',
self::STATE_HIDDEN => 'hidden',
self::STATE_REVIEW => 'review',
];
/**
* Action associated with the line item.
* It is recommended to add custom action types between 100 and 1000
*/
// Conversation's status changed
const ACTION_TYPE_STATUS_CHANGED = 1;
// Conversation's assignee changed
const ACTION_TYPE_USER_CHANGED = 2;
// The conversation was moved from another mailbox
const ACTION_TYPE_MOVED_FROM_MAILBOX = 3;
// Another conversation was merged with this conversation
const ACTION_TYPE_MERGED = 4;
// The conversation was imported (no email notifications were sent)
const ACTION_TYPE_IMPORTED = 5;
// A workflow was run on this conversation (either automatic or manual)
// const ACTION_TYPE_WORKFLOW_MANUAL = 6;
// const ACTION_TYPE_WORKFLOW_AUTO = 7;
// The ticket was imported from an external Service
const ACTION_TYPE_IMPORTED_EXTERNAL = 8;
// Conversation customer changed
const ACTION_TYPE_CUSTOMER_CHANGED = 9;
// The ticket was deleted
const ACTION_TYPE_DELETED_TICKET = 10;
// The ticket was restored
const ACTION_TYPE_RESTORE_TICKET = 11;
// Describes an optional action associated with the line item
public static $action_types = [
self::ACTION_TYPE_STATUS_CHANGED => 'changed-ticket-status',
self::ACTION_TYPE_USER_CHANGED => 'changed-ticket-assignee',
self::ACTION_TYPE_MOVED_FROM_MAILBOX => 'moved-from-mailbox',
self::ACTION_TYPE_MERGED => 'merged',
self::ACTION_TYPE_IMPORTED => 'imported',
// self::ACTION_TYPE_WORKFLOW_MANUAL => 'manual-workflow',
// self::ACTION_TYPE_WORKFLOW_AUTO => 'automatic-workflow',
self::ACTION_TYPE_IMPORTED_EXTERNAL => 'imported-external',
self::ACTION_TYPE_CUSTOMER_CHANGED => 'changed-ticket-customer',
self::ACTION_TYPE_DELETED_TICKET => 'deleted-ticket',
self::ACTION_TYPE_RESTORE_TICKET => 'restore-ticket',
];
/**
* Source types (equal to thread source types).
*/
const SOURCE_TYPE_EMAIL = 1;
const SOURCE_TYPE_WEB = 2;
const SOURCE_TYPE_API = 3;
public static $source_types = [
self::SOURCE_TYPE_EMAIL => 'email',
self::SOURCE_TYPE_WEB => 'web',
self::SOURCE_TYPE_API => 'api',
];
// Metas.
const META_CONVERSATION_HISTORY = 'ch';
const META_PREV_CONVERSATION = 'pc';
const META_MERGED_WITH_CONV = 'mwc';
const META_MERGED_INTO_CONV = 'mic';
const META_FORWARD_PARENT_CONVERSATION_NUMBER = 'fw_pcn';
const META_FORWARD_PARENT_CONVERSATION_ID = 'fw_pci';
const META_FORWARD_PARENT_THREAD_ID = 'fw_pti';
const META_FORWARD_CHILD_CONVERSATION_NUMBER = 'fw_ccn';
const META_FORWARD_CHILD_CONVERSATION_ID = 'fw_cci';
// At some stage metas have been renamed.
public static $meta_fw_backward_compat = [
self::META_FORWARD_PARENT_CONVERSATION_NUMBER => 'forward_parent_conversation_number',
self::META_FORWARD_PARENT_CONVERSATION_ID => 'forward_parent_conversation_id',
self::META_FORWARD_PARENT_THREAD_ID => 'forward_parent_thread_id',
self::META_FORWARD_CHILD_CONVERSATION_NUMBER => 'forward_child_conversation_number',
self::META_FORWARD_CHILD_CONVERSATION_ID => 'forward_child_conversation_id',
];
protected $dates = [
'opened_at',
'created_at',
'updated_at',
'deleted_at',
'edited_at',
];
protected $casts = [
'meta' => 'array',
];
/**
* The user assigned to this thread (assignedTo).
*/
public function user()
{
return $this->belongsTo('App\User');
}
/**
* The user assigned to this thread (cached).
*/
public function user_cached()
{
return $this->user()->rememberForever();
}
/**
* Get the thread customer.
*/
public function customer()
{
return $this->belongsTo('App\Customer');
}
/**
* Get the thread customer (cached).
*/
public function customer_cached()
{
return $this->customer()->rememberForever();
}
/**
* Get conversation.
*/
public function conversation()
{
return $this->belongsTo('App\Conversation');
}
/**
* Get thread attachmets.
*/
public function attachments()
{
return $this->hasMany('App\Attachment')->where('embedded', false);
//return $this->hasMany('App\Attachment');
}
/**
* Get thread embedded attachments.
*/
public function embeds()
{
return $this->hasMany('App\Attachment')->where('embedded', true);
}
/**
* All kinds of attachments including embedded.
*/
public function all_attachments()
{
return $this->hasMany('App\Attachment');
}
/**
* Get user who created the thread.
*/
public function created_by_user()
{
return $this->belongsTo('App\User');
}
/**
* Get user who created the thread (cached).
*/
public function created_by_user_cached()
{
return $this->created_by_user()->rememberForever();
}
/**
* Get customer who created the thread.
*/
public function created_by_customer()
{
return $this->belongsTo('App\Customer');
}
/**
* Get user who edited thread.
*/
public function edited_by_user()
{
return $this->belongsTo('App\User');
}
/**
* Get user who edited thread (cached).
*/
public function edited_by_user_cached()
{
return $this->edited_by_user()->rememberForever();
}
/**
* Get sanitized body HTML.
*
* @return string
*/
public function getCleanBody($body = '')
{
if (!$body) {
$body = $this->body;
}
if ($body === null) {
$body = '';
}
// Change "background:" to "background-color:".
// https://github.com/freescout-helpdesk/freescout/issues/2560
// Keep in mind that with large texts preg_replace() may return null.
$body = preg_replace("/(<[^<>]+style=[\"'][^\"']*)background: *([^;() ]+[;\"'])/", '$1background-color:$2', $body) ?: $body;
// Cut out "collapse" class as it hides elements.
$body = preg_replace("/(<[^<>\r\n]+class=([\"'][^\"']* |[\"']))(collapse|hidden)([\"' ])/", '$1$4', $body) ?: $body;
return \Helper::purifyHtml($body);
}
/**
* Convert body to plain text.
*/
public function getBodyAsText($options = ['width' => 0])
{
return \Helper::htmlToText($this->body, true, $options);
}
public function getBodyWithFormatedLinks(string $body = '') :string
{
if (!$body) {
$body = $this->body;
}
$body = \Helper::linkify($this->getCleanBody($body));
// Add target="_blank" to links.
$pattern = '/<a(.*?)?href=[\'"]?[\'"]?(.*?)?>/i';
$body = preg_replace_callback($pattern, function($m){
$tpl = array_shift($m);
$href = isset($m[1]) ? $m[1] : null;
if (preg_match('/target=[\'"]?(.*?)[\'"]?/i', $tpl)) {
return $tpl;
}
if (trim($href) && 0 === strpos($href, '#')) {
// Anchor links.
return $tpl;
}
return preg_replace_callback('/href=/i', function($m2){
return sprintf('target="_blank" %s', array_shift($m2));
}, $tpl);
}, $body) ?: $body;
return $body;
}
/**
* Get sanitized body HTML.
*
* @return string
*/
public function getCleanBodyOriginal()
{
return $this->getCleanBody($this->body_original);
}
/**
* Get thread recipients.
*
* @return array
*/
public function getToArray($exclude_array = [])
{
return \App\Misc\Helper::jsonToArray($this->to, $exclude_array);
}
public function getToString($exclude_array = [])
{
return implode(', ', $this->getToArray($exclude_array));
}
/**
* Get first address from the To list.
*/
public function getToFirst()
{
$to = $this->getToArray();
return array_shift($to);
}
/**
* Get type name.
*/
public function getTypeName()
{
return self::$types[$this->type];
}
/**
* Get thread CC recipients.
*
* @return array
*/
public function getCcArray($exclude_array = [])
{
return \App\Misc\Helper::jsonToArray($this->cc, $exclude_array);
}
public function getCcString($exclude_array = [])
{
return implode(', ', $this->getCcArray($exclude_array));
}
/**
* Get thread BCC recipients.
*
* @return array
*/
public function getBccArray($exclude_array = [])
{
return \App\Misc\Helper::jsonToArray($this->bcc, $exclude_array);
}
public function getBccString($exclude_array = [])
{
return implode(', ', $this->getBccArray($exclude_array));
}
/**
* Set to as JSON.
*/
public function setTo($emails)
{
$emails_array = Conversation::sanitizeEmails($emails);
if ($emails_array) {
$emails_array = array_unique($emails_array);
$this->to = \Helper::jsonEncodeUtf8($emails_array);
} else {
$this->to = null;
}
}
public function setCc($emails)
{
$emails_array = Conversation::sanitizeEmails($emails);
if ($emails_array) {
$emails_array = array_unique($emails_array);
$this->cc = \Helper::jsonEncodeUtf8($emails_array);
} else {
$this->cc = null;
}
}
public function setBcc($emails)
{
$emails_array = Conversation::sanitizeEmails($emails);
if ($emails_array) {
$emails_array = array_unique($emails_array);
$this->bcc = \Helper::jsonEncodeUtf8($emails_array);
} else {
$this->bcc = null;
}
}
/**
* Get thread's status name.
*
* @return string
*/
public function getStatusName()
{
return self::statusCodeToName($this->status);
}
/**
* Get status name. Made as a function to allow status names translation.
*
* @param int $status
*
* @return string
*/
public static function statusCodeToName($status)
{
switch ($status) {
case self::STATUS_ACTIVE:
return __('Active');
break;
case self::STATUS_PENDING:
return __('Pending');
break;
case self::STATUS_CLOSED:
return __('Closed');
break;
case self::STATUS_SPAM:
return __('Spam');
break;
case self::STATUS_NOCHANGE:
return __('Not changed');
break;
default:
return '';
break;
}
}
/**
* Get text for the assignee in line item.
*
* @return string
*/
public function getAssigneeName($ucfirst = false, $by_user = null)
{
if (!$by_user) {
$by_user = auth()->user();
}
if (!$this->user_id) {
if ($ucfirst) {
return __('Anyone');
} else {
return __('anyone');
}
} elseif ($by_user && $this->user_id == $by_user->id) {
if ($this->created_by_user_id && $this->created_by_user_id == $this->user_id) {
$name = __('yourself');
} else {
$name = __('you');
}
if ($ucfirst) {
$name = ucfirst($name);
}
return $name;
} else {
// User may be deleted
if ($this->user) {
return $this->user->getFullName();
} else {
return '';
}
}
}
/**
* Get user or customer who created the thead.
*/
public function getCreatedBy()
{
if (!empty($this->created_by_user_id)) {
// User can be deleted
if ($this->created_by_user) {
return $this->created_by_user;
} else {
return \App\User::getDeletedUser();
}
} else {
return $this->created_by_customer;
}
}
/**
* Get creator of the thread.
*/
public function getPerson($cached = false)
{
if ($this->type == self::TYPE_CUSTOMER) {
if ($cached) {
return $this->customer_cached;
} else {
return $this->customer;
}
} else {
if ($cached) {
return $this->created_by_user_cached;
} else {
return $this->created_by_user;
}
}
}
/**
* Get action's person.
*/
public function getActionPerson($conversation_number = '')
{
$person = '';
if ($this->type == self::TYPE_CUSTOMER) {
if ($this->customer_cached) {
$person = $this->customer_cached->getFullName(true);
}
} elseif ($this->state == self::STATE_DRAFT && !empty($this->edited_by_user_id)) {
// Draft
if (auth()->user() && $this->edited_by_user_id == auth()->user()->id) {
$person = __('you');
} else {
$person = $this->edited_by_user->getFullName();
}
} elseif ($this->created_by_user_cached) {
if ($this->created_by_user_id && auth()->user() && $this->created_by_user_cached->id == auth()->user()->id) {
$person = __('you');
} else {
$person = $this->created_by_user_cached->getFullName();
}
}
// https://github.com/tormjens/eventy/issues/19
$person = \Eventy::filter('thread.action_person', $person, $this, $conversation_number);
return $person;
}
/**
* Get action text.
* $by_user - user who performed the action.
* $person must be already escaped.
*/
public function getActionText($conversation_number = '', $escape = false, $strip_tags = false, $by_user = null, $person = '', $viewed_by_user = null)
{
$did_this = '';
// Did this
if ($this->type == self::TYPE_LINEITEM) {
if ($this->action_type == self::ACTION_TYPE_STATUS_CHANGED) {
if ($conversation_number) {
$did_this = __(':person marked as :status_name conversation #:conversation_number', ['status_name' => $this->getStatusName(), 'conversation_number' => $conversation_number]);
} else {
$did_this = __(":person marked as :status_name", ['status_name' => $this->getStatusName()]);
}
} elseif ($this->action_type == self::ACTION_TYPE_USER_CHANGED) {
$assignee = $this->getAssigneeName(false, $by_user);
if ($escape) {
$assignee = htmlspecialchars($assignee);
}
if ($conversation_number) {
$did_this = __(':person assigned :assignee conversation #:conversation_number', ['assignee' => $assignee, 'conversation_number' => $conversation_number]);
} else {
$did_this = __(":person assigned to :assignee", ['assignee' => $assignee]);
}
} elseif ($this->action_type == self::ACTION_TYPE_CUSTOMER_CHANGED) {
if ($conversation_number) {
$did_this = __(':person changed the customer to :customer in conversation #:conversation_number', ['customer' => $this->customer->getFullName(true), 'conversation_number' => $conversation_number]);
} else {
$customer_name = '';
if ($this->customer_cached) {
$customer_name = $this->customer_cached->getFullName(true);
}
if ($escape) {
$customer_name = htmlspecialchars($customer_name);
}
$did_this = __(":person changed the customer to :customer", ['customer' => '<a href="'.($this->customer_cached ? $this->customer_cached->url() : '').'" title="'.$this->action_data.'" class="link-black">'.$customer_name.'</a>']);
}
} elseif ($this->action_type == self::ACTION_TYPE_DELETED_TICKET) {
$did_this = __(":person deleted");
} elseif ($this->action_type == self::ACTION_TYPE_RESTORE_TICKET) {
$did_this = __(":person restored");
} elseif ($this->action_type == self::ACTION_TYPE_MOVED_FROM_MAILBOX) {
$did_this = __(":person moved conversation from another mailbox");
} elseif ($this->action_type == self::ACTION_TYPE_MERGED) {
if (!empty($this->getMeta(Thread::META_MERGED_WITH_CONV))) {
$did_this = __(":person merged with another conversation");
} else {
$merge_conversation = Conversation::find($this->getMeta(Thread::META_MERGED_INTO_CONV));
$merge_conversation_number = '';
if ($merge_conversation) {
$merge_conversation_number = $merge_conversation->number;
}
if ($merge_conversation) {
$did_this = __(":person merged into conversation #:conversation_number", ['conversation_number' => '<a href="'.$merge_conversation->url().'" class="link-black">'.$merge_conversation_number.'</a>']);
} else {
$did_this = __(":person merged into conversation #:conversation_number", ['conversation_number' => $merge_conversation_number]);
}
}
}
} elseif ($this->state == self::STATE_DRAFT) {
if (empty($this->edited_by_user_id)) {
$did_this = __(':person created a draft');
} else {
$did_this = __(":person edited :creator's draft", ['creator' => $this->created_by_user_cached->getFirstName()]);
}
} else {
if ($this->isForwarded()) {
$did_this = __(':person forwarded a conversation #:forward_parent_conversation_number', ['forward_parent_conversation_number' => $this->getMetaFw(self::META_FORWARD_PARENT_CONVERSATION_NUMBER)]);
} elseif ($this->first) {
$did_this = __(':person started a new conversation #:conversation_number', ['conversation_number' => $conversation_number]);
} elseif ($this->type == self::TYPE_NOTE) {
$did_this = __(':person added a note to conversation #:conversation_number', ['conversation_number' => $conversation_number]);
} else {
$did_this = __(':person replied to conversation #:conversation_number', ['conversation_number' => $conversation_number]);
}
}
$did_this = \Eventy::filter('thread.action_text', $did_this, $this, $conversation_number, $escape, $viewed_by_user);
if ($strip_tags) {
$did_this = strip_tags($did_this);
}
if ($person) {
// This causes double escaping.
// if ($escape) {
// $person = htmlspecialchars($person);
// }
$did_this = str_replace(':person', $person, $did_this);
}
return $did_this;
}
/**
* Description of what happened.
*/
public function getActionDescription($conversation_number, $escape = true, $viewed_by_user = null)
{
// Person
$person = $this->getActionPerson($conversation_number);
$did_this = $this->getActionText($conversation_number, false, false, null, '', $viewed_by_user);
if ($escape) {
$person = htmlspecialchars($person);
$did_this = htmlspecialchars($did_this);
}
return __($did_this, [
'person' => '<strong>'.$person.'</strong>',
'did_this' => $did_this,
]);
}
/**
* Get thread state name.
*/
public function getStateName()
{
return self::$states[$this->state];
}
public function deleteThread()
{
$this->deteleAttachments();
$this->delete();
if ($this->isNote()) {
Conversation::updatePreview($this->conversation_id);
}
}
/**
* Delete thread attachments.
*/
public function deteleAttachments()
{
Attachment::deleteByIds($this->all_attachments()->pluck('id')->toArray());
}
public function isDraft()
{
return $this->state == self::STATE_DRAFT;
}
/**
* Get original body or body.
*/
public function getBodyOriginal()
{
if (!empty($this->body_original)) {
return $this->body_original;
} else {
return $this->body;
}
}
/**
* Get name for the reply to customer.
*
* @param [type] $mailbox [description]
*
* @return [type] [description]
*/
public function getFromName($mailbox = null)
{
// Created by customer
if ($this->source_via == self::PERSON_CUSTOMER) {
if ($this->getCreatedBy()) {
return $this->getCreatedBy()->getFirstName(true);
} else {
return '';
}
}
// Created by user
if (empty($mailbox)) {
$mailbox = $this->conversation->mailbox;
}
// Mailbox name by default
$name = $mailbox->name;
if ($mailbox->from_name == Mailbox::FROM_NAME_CUSTOM && $mailbox->from_name_custom) {
$name = $mailbox->from_name_custom;
} elseif ($mailbox->from_name == Mailbox::FROM_NAME_USER && $this->getCreatedBy()) {
$name = $this->getCreatedBy()->getFirstName(true);
}
return $name;
}
/**
* Check if thread is a reply from customer or user.
*
* @return bool [description]
*/
public function isReply()
{
return in_array($this->type, [\App\Thread::TYPE_MESSAGE, \App\Thread::TYPE_CUSTOMER]);
}
/**
* Is this thread created from auto responder email.
*
* @return bool [description]
*/
public function isAutoResponder()
{
return \MailHelper::isAutoResponder($this->headers);
}
/**
* Is thread created from incoming bounce email.
*
* @return bool [description]
*/
public function isBounce()
{
if (!empty($this->getSendStatusData()['is_bounce'])) {
return true;
} else {
return false;
}
}
/**
* Send status data mayb contain the following information:
* - bounce info (status_code, action, diagnostic_code, is_bounce, bounce_for_thread, bounce_for_conversation, bounced_by_thread, bounced_by_conversation)
* - send error message
* - click date
* - unsubscribe date
* - complain date.
*
* @return [type] [description]
*/
public function getSendStatusData()
{
return \Helper::jsonToArray($this->send_status_data);
}
public function updateSendStatusData($new_data)
{
if ($new_data) {
$send_status_data = $this->getSendStatusData();
if ($send_status_data) {
$send_status_data = array_merge($send_status_data, $new_data);
} else {
$send_status_data = $new_data;
}
$this->send_status_data = \Helper::jsonEncodeUtf8($send_status_data);
} else {
$this->send_status_data = null;
}
}
public function isSendStatusError()
{
return in_array($this->send_status, \App\SendLog::$status_errors);
}
/**
* Create thread.
*
* @param [type] $conversation_id [description]
* @param [type] $text [description]
* @param array $data [description]
* @return [type] [description]
*/
public static function create($conversation, $type, $body, $data = [], $save = true)
{
$thread = new Thread();
$thread->conversation_id = $conversation->id;
$thread->type = $type;
$thread->body = $body;
$thread->status = $conversation->status;
$thread->state = Thread::STATE_PUBLISHED;
// Assigned to.
if (!empty($data['user_id'])) {
$thread->user_id = $data['user_id'];
}
if (!empty($data['message_id'])) {
$thread->message_id = $data['message_id'];
}
if (!empty($data['headers'])) {
$thread->headers = $data['headers'];
}
if (!empty($data['from'])) {
$thread->from = $data['from'];
}
if (!empty($data['to'])) {
$thread->setTo($data['to']);
}
if (!empty($data['cc'])) {
$thread->setCc($data['cc']);
}
if (!empty($data['bcc'])) {
$thread->setBcc($data['bcc']);
}
if (isset($data['first'])) {
$thread->from = $data['first'];
}
if (isset($data['source_via'])) {
$thread->source_via = $data['source_via'];
}
if (isset($data['source_type'])) {
$thread->source_type = $data['source_type'];
}
if (!empty($data['customer_id'])) {
$thread->customer_id = $data['customer_id'];
}
if (!empty($data['created_by_customer_id'])) {
$thread->created_by_customer_id = $data['created_by_customer_id'];
}
if (!empty($data['created_by_user_id'])) {
$thread->created_by_user_id = $data['created_by_user_id'];
}
if (!empty($data['action_type'])) {
$thread->action_type = $data['action_type'];
}
if (!empty($data['meta'])) {
$thread->setMetas($data['meta']);
}
if ($save) {
$thread->save();
}
return $thread;
}
public static function createExtended($data = [], $conversation = null, $customer = null, $update_conv = true)
{
if (empty($data['type']) || empty($data['body'])) {
return false;
}
$is_customer = ($data['type'] == Thread::TYPE_CUSTOMER);
if (!$customer && !empty($data['customer_id'])) {
$customer = Customer::find($data['customer_id']);
}
if (!$customer) {
$customer = $conversation->customer;
}
// User which creatd the thread should be passed in created_by_user_id.
// user_id is check for backward compatibility.
$user_id = $data['created_by_user_id'] ?? $data['user_id'] ?? null;
// Check type.
if ($data['type'] == Thread::TYPE_CUSTOMER && empty($customer)) {
return false;
//return $this->getErrorResponse('`customer` parameter is required', 'customer');
}
if (($data['type'] == Thread::TYPE_MESSAGE || $data['type'] == Thread::TYPE_NOTE) && empty($user_id)) {
return false;
//return $this->getErrorResponse('`user` parameter is required', 'user');
}
// Create thread.
$now = date('Y-m-d H:i:s');
// New conversation.
$new = !$conversation->threads_count;
$thread = new Thread();
$thread->conversation_id = $conversation->id;
$thread->type = $data['type'];
if ($is_customer) {
$thread->source_via = Thread::PERSON_CUSTOMER;
$thread->created_by_customer_id = $customer->id;
} else {
$thread->source_via = Thread::PERSON_USER;
$thread->created_by_user_id = $user_id;
$thread->edited_by_user_id = null;
$thread->edited_at = null;
}
$thread->source_type = Thread::SOURCE_TYPE_API;
$thread->state = $data['state'] ?? Thread::STATE_PUBLISHED;
$thread->customer_id = $customer->id ?? $conversation->customer_id ?? null;
$thread->body = $data['body'];
if (!$is_customer) {
$thread->setTo([$customer->getMainEmail()]);
}
$cc = \MailHelper::sanitizeEmails($data['cc'] ?? []);
$thread->setCc($cc);
$bcc = \MailHelper::sanitizeEmails($data['bcc'] ?? []);
$thread->setBcc($bcc);
$thread->imported = (int)($data['imported'] ?? false);
if ($thread->imported && !empty($data['created_at'])) {
$thread->created_at = self::utcStringToServerDate($data['created_at']);
}
if ($new) {
$thread->first = true;
}
// Assignee.
if (empty($data['user_id'])) {
$thread->user_id = $conversation->user_id;
}
// Process attachments.
if (!empty($data['attachments'])) {
$has_attachments = false;
foreach ($data['attachments'] as $attachment) {
$content = null;
$uploaded_file = null;
if (is_object($attachment) && get_class($attachment) == 'Illuminate\Http\UploadedFile') {
$uploaded_file = $attachment;
$attachment = [];
$attachment['file_name'] = $uploaded_file->getClientOriginalName();
$attachment['mime_type'] = $uploaded_file->getMimeType();
} else {
if (empty($attachment['file_name'])
//|| empty($attachment['mime_type'])
|| (empty($attachment['data']) && empty($attachment['file_url']))
) {
continue;
}
if (!empty($attachment['data'])) {
// BASE64 string.
$content = base64_decode($attachment['data']);
if (!$content) {
continue;
}
if (empty($attachment['mime_type'])) {
$f = finfo_open();
$attachment['mime_type'] = finfo_buffer($f, $content, FILEINFO_MIME_TYPE);
}
} else {
// URL.
$file_path = \Helper::downloadRemoteFileAsTmp($attachment['file_url']);
if (!$file_path) {
continue;
}
$uploaded_file = new \Illuminate\Http\UploadedFile(
$file_path, basename($file_path),
null, null, true
);
if (empty($attachment['mime_type'])) {
$attachment['mime_type'] = mime_content_type($file_path);
if (empty($attachment['mime_type'])) {
$attachment['mime_type'] = $uploaded_file->getMimeType();
}
}
}
}
if (!$has_attachments) {
$thread->save();
}
$attachment = Attachment::create(
$attachment['file_name'],
$attachment['mime_type'],
null,
$content,
$uploaded_file,
$embedded = false,
$thread->id,
$user_id ?? null
);
if ($attachment) {
$has_attachments = true;
}
}
if ($has_attachments) {
$thread->has_attachments = true;
$conversation->has_attachments = true;
}
} else {
$has_attachments = $data['has_attachments'] ?? false;
$thread->has_attachments = $has_attachments;
$conversation->has_attachments = $has_attachments;
}
$thread->save();
if ($new) {
if ($is_customer) {
$conversation->source_via = Conversation::PERSON_CUSTOMER;
$conversation->created_by_customer_id = $customer->id;
} else {
$conversation->source_via = Conversation::PERSON_USER;
$conversation->created_by_user_id = $user_id;
}
}
$conversation->setCc($cc);
// BCC should keep BCC of the first email,
// so we change BCC only if it contains emails.
if ($bcc) {
$conversation->setBcc($bcc);
}
$update_folder = false;
if ($thread->isReply()) {
$conversation->last_reply_at = $now;
if ($is_customer) {
$conversation->last_reply_from = Conversation::PERSON_CUSTOMER;
// Set specific status
if (!empty($data['status'])) {
if ((int)$conversation->status != (int)$data['status']) {
$update_folder = true;
}
$conversation->status = $data['status'];
} else {
if ((int)$conversation->status != Conversation::STATUS_ACTIVE) {
$update_folder = true;
}
// Reply from customer makes conversation active
$conversation->status = Conversation::STATUS_ACTIVE;
}
} else {
$conversation->last_reply_from = Conversation::PERSON_USER;
$conversation->user_updated_at = $now;
if (!empty($data['status'])) {
if ((int)$conversation->status != (int)$data['status']) {
$update_folder = true;
}
$conversation->status = $data['status'];
} else {
if ((int)$conversation->status != Conversation::STATUS_PENDING) {
$update_folder = true;
}
// Reply from customer makes conversation active
$conversation->status = Conversation::STATUS_PENDING;
}
}
}
// Reply from customer to deleted conversation should undelete it.
if ($data['type'] == Thread::TYPE_CUSTOMER && $conversation->state == Conversation::STATE_DELETED) {
$conversation->state = Conversation::STATE_PUBLISHED;
$update_folder = true;
}
if ($update_conv) {
$conversation->customer_id = $customer->id;
if ($is_customer) {
$conversation->customer_email = $customer->getMainEmail();
}
if ($update_folder) {
$conversation->updateFolder();
}
}
// Update conversation here if needed.
if ($is_customer) {
if ($new) {
$conversation = \Eventy::filter('conversation.created_by_customer', $conversation, $thread, $customer);
} else {
$conversation = \Eventy::filter('conversation.customer_replied', $conversation, $thread, $customer);
}
}
// save() will check if something in the model has changed. If it hasn't it won't run a db query.
$conversation->save();
// Update folders counters
if (!$new) {
// Update folders counters
$conversation->mailbox->updateFoldersCounters();
}
// Events.
// Conversation customer changed
// Not used anywhere
// if ($prev_customer_id) {
// event(new ConversationCustomerChanged($conversation, $prev_customer_id, $prev_customer_email, null, $customer));
// }
if ($new) {
if ($is_customer) {
event(new CustomerCreatedConversation($conversation, $thread));
\Eventy::action('conversation.created_by_customer', $conversation, $thread, $customer);
} else {
// New conversation.
event(new UserCreatedConversation($conversation, $thread));
\Eventy::action('conversation.created_by_user_can_undo', $conversation, $thread);
// After Conversation::UNDO_TIMOUT period trigger final event.
\Helper::backgroundAction('conversation.created_by_user', [$conversation, $thread], now()->addSeconds(Conversation::UNDO_TIMOUT));
}
} elseif ($data['type'] == Thread::TYPE_NOTE) {
// Note.
event(new UserAddedNote($conversation, $thread));
\Eventy::action('conversation.note_added', $conversation, $thread);
} else {
// Reply.
if ($is_customer) {
event(new CustomerReplied($conversation, $thread));
\Eventy::action('conversation.customer_replied', $conversation, $thread, $customer);
} else {
event(new UserReplied($conversation, $thread));
\Eventy::action('conversation.user_replied_can_undo', $conversation, $thread);
// After Conversation::UNDO_TIMOUT period trigger final event.
\Helper::backgroundAction('conversation.user_replied', [$conversation, $thread], now()->addSeconds(Conversation::UNDO_TIMOUT));
}
}
return $thread;
}
/**
* Get full name of the user who edited thread.
*/
public function getEditedByUserName()
{
$name = '';
if (!$this->edited_by_user_id) {
return '';
}
if (auth()->user() && $this->edited_by_user_id == auth()->user()->id) {
$name = __('you');
} else {
$name = $this->edited_by_user_cached->getFullName();
}
return $name;
}
/**
* Get thread meta data as array.
*/
public function getMetas()
{
return $this->meta;
//return \Helper::jsonToArray($this->meta);
}
/**
* Set thread meta value.
*/
public function setMetas($data)
{
$this->meta = $data;
//$this->meta = \Helper::jsonEncodeUtf8($data);
}
/**
* Get thread meta value.
*/
public function getMeta($key, $default = null)
{
$metas = $this->getMetas();
if (isset($metas[$key])) {
return $metas[$key];
} else {
return $default;
}
}
/**
* Set thread meta value.
*/
public function setMeta($key, $value)
{
$metas = $this->getMetas();
$metas[$key] = $value;
$this->setMetas($metas);
}
public function getMetaFw($key, $default = null)
{
$meta = $this->getMeta($key, $default);
if (!$meta) {
$meta = $this->getMeta(self::$meta_fw_backward_compat[$key], $default);
}
return $meta;
}
/**
* Unset thread meta value.
*/
public function unsetMeta($key)
{
$metas = $this->getMetas();
if (isset($metas[$key])) {
unset($metas[$key]);
$this->setMetas($metas);
}
}
/**
* Get full name of the user who forwarded conversation.
*/
public function getForwardByFullName($by_user = null)
{
if (!$by_user) {
$by_user = auth()->user();
}
if ($by_user && $this->created_by_user_id == $by_user->id) {
$name = __('you');
} else {
$name = $this->created_by_user->getFullName();
}
return $name;
}
/**
* Is this a note informing that conversation has been forwarded.
*/
public function isForward()
{
return ($this->subtype == \App\Thread::SUBTYPE_FORWARD);
}
/**
* Is this a forwarded conversation.
*/
public function isForwarded()
{
if ($this->getMetaFw(self::META_FORWARD_PARENT_CONVERSATION_ID)) {
return true;
} else {
return false;
}
}
/**
* Is this thread a note.
*/
public function isNote()
{
return ($this->type == \App\Thread::TYPE_NOTE);
}
/**
* Get forwarded conversation.
*/
public function getForwardParentConversation()
{
return Conversation::where('id', $this->getMetaFw(self::META_FORWARD_PARENT_CONVERSATION_ID))
->rememberForever()
->first();
}
/**
* Get forward child conversation.
*/
public function getForwardChildConversation()
{
return Conversation::where('id', $this->getMetaFw(self::META_FORWARD_CHILD_CONVERSATION_ID))
->first();
}
/**
* Fetch body via IMAP.
*/
public function fetchBody()
{
$message = \MailHelper::fetchMessage($this->conversation->mailbox, $this->message_id, $this->getMailDate());
if (!$message) {
return '';
}
$body = $message->getHTMLBody();
if (!$body) {
$body = $message->getTextBody();
}
return $body;
}
public function parseHeaders()
{
return \MailHelper::parseHeaders($this->headers);
}
public function getMailDate()
{
$data = $this->parseHeaders();
if (empty($data->date)) {
return null;
}
return \Helper::parseDateToCarbon($data->date);
}
public function getActionTypeName()
{
if (!$this->action_type) {
return '';
}
$action_types = \Eventy::filter('thread.action_types', self::$action_types);
return self::$action_types[$this->action_type] ?? '';
}
public function isCustomerMessage()
{
return $this->type == self::TYPE_CUSTOMER;
}
public function isUserMessage()
{
return $this->type == self::TYPE_MESSAGE;
}
public static function replaceBase64ImagesWithAttachments($body, $user_id = null)
{
\Helper::setPcreBacktrackLimit();
$body = preg_replace_callback("#(<img[^<>]+src=[\"'])data:image/([^;]+);base64,([^\"']+)([\"'])#",
function ($match) {
$attachment = null;
$data = base64_decode($match[3]);
if ($data) {
$attachment = Attachment::create(
$file_name = number_format(microtime(true), 4, '', '').'.'.$match[2],
$mime_type = 'image/'.$match[2],
$type = Attachment::TYPE_IMAGE,
$data,
$uploaded_file = null,
$embedded = true,
$thread_id = null,
$user_id = \Auth::id()
);
}
if ($attachment) {
return $match[1].$attachment->url().$match[4];
} else {
return $match[0];
}
}, $body
);
return $body;
}
public function getMessageId($mailbox = null)
{
if ($this->isCustomerMessage() && $this->message_id) {
return $this->message_id;
}
if ($this->isUserMessage()) {
if (!$mailbox) {
$mailbox = $this->conversation->mailbox;
}
return \MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER.'-'.$this->id.'-'.\MailHelper::getMessageIdHash($this->id).'@'.$mailbox->getEmailDomain();
}
return '';
}
// Sorts threads in desc order by created_at and ID.
//
// Threads has to be sorted by created_at and not by id.
// https://github.com/freescout-helpdesk/freescout/issues/2938
// Sometimes thread.created_at may be the same,
// in such cases we also need to sort by thread ID.
public static function sortThreads($threads)
{
return $threads->sort(function ($a, $b) {
$a_ts = $a->created_at->getTimestamp();
$b_ts = $b->created_at->getTimestamp();
if ($a_ts == $b_ts) {
if ($a->id < $b->id) {
return 1;
} else {
return -1;
}
} else {
return ($a_ts < $b_ts) ? 1 : -1;
}
});
}
public static function getLastThread($threads)
{
$threads = self::sortThreads($threads);
return $threads->first();
}
public function canRetrySend()
{
if (!in_array($this->send_status, [SendLog::STATUS_SEND_ERROR, SendLog::STATUS_DELIVERY_ERROR])) {
return false;
}
// Check if failed_job still exists.
if (!$this->getFailedJobId()) {
return false;
}
return true;
}
public function getFailedJobId()
{
return \App\FailedJob::where('queue', 'emails')
->where('payload', 'like', '{"displayName":"App\\\\\\\\Jobs\\\\\\\\SendReplyToCustomer"%{i:0;i:'.$this->id.';%')
->value('id');
}
}