freescout-restricted-customers/Http/Controllers/ConversationsController.php

742 lines
26 KiB
PHP

<?php
/*
SPDX-License-Identifier: AGPL-3.0-only
SPDX-FileCopyrightText: © 2024 Millions Missing FRANCE <info@millionsmissing.fr>
*/
namespace Modules\MMFRestrictedCustomers\Http\Controllers;
use Validator;
use Illuminate\Http\Request;
use App\Attachment;
use App\Conversation;
use App\Events\ConversationStatusChanged;
use App\Events\ConversationUserChanged;
use App\Events\UserAddedNote;
use App\Events\UserCreatedConversation;
use App\Events\UserReplied;
use App\Mailbox;
use App\MailboxUser;
use App\Thread;
use Modules\MMFRestrictedCustomers\Entities\Customer;
use App\Http\Controllers\ConversationsController as BaseConversationsController;
class ConversationsController extends BaseConversationsController {
/**
* Conversations ajax controller.
*/
public function ajax(Request $request) {
// This override is only required for "send_reply" requests.
if ( $request->action != 'send_reply' )
return parent::ajax($request);
$response = [
'status' => 'error',
'msg' => '', // this is error message
];
$user = auth()->user();
$mailbox = Mailbox::findOrFail($request->mailbox_id);
if (!$response['msg'] && !$user->can('view', $mailbox)) {
$response['msg'] = __('Not enough permissions');
}
$conversation = null;
if (!$response['msg'] && !empty($request->conversation_id)) {
$conversation = Conversation::find($request->conversation_id);
if ($conversation && !$user->can('view', $conversation)) {
$response['msg'] = __('Not enough permissions');
}
}
$new = false;
if (empty($request->conversation_id)) {
$new = true;
}
$is_note = false;
if (!empty($request->is_note)) {
$is_note = true;
}
// Conversation type.
$type = Conversation::TYPE_EMAIL;
if (!empty($request->type)) {
$type = (int)$request->type;
} elseif ($conversation) {
$type = $conversation->type;
}
$is_phone = false;
if ($type == Conversation::TYPE_PHONE) {
$is_phone = true;
}
$is_custom = false;
if ($type == Conversation::TYPE_CUSTOM) {
$is_custom = true;
}
$is_create = false;
if (!empty($request->is_create)) {
//if ($new || ($from_draft && $conversation->threads_count == 1)) {
$is_create = $request->is_create;
}
$is_forward = false;
if (!empty($request->subtype) && (int)$request->subtype == Thread::SUBTYPE_FORWARD) {
$is_forward = true;
}
$is_multiple = false;
if (!empty($request->multiple_conversations)) {
$is_multiple = true;
}
// If reply is being created from draft, there is already thread created
$thread = null;
$from_draft = false;
if (( ! $is_note || $is_phone || $is_custom ) && ! $response['msg'] && ! empty($request->thread_id)) {
$thread = Thread::find($request->thread_id);
if ($thread && (!$conversation || $thread->conversation_id != $conversation->id)) {
$response['msg'] = __('Incorrect thread');
} else {
$from_draft = true;
}
}
if (!$response['msg']) {
if ($thread && $from_draft && $thread->state == Thread::STATE_PUBLISHED) {
$response['msg'] = __('Message has been already sent. Please discard this draft.');
}
}
// Validate form
if (!$response['msg']) {
if ($new) {
if ($type == Conversation::TYPE_EMAIL) {
$validator = Validator::make($request->all(), [
'to' => 'required|array',
'subject' => 'required|string|max:998',
'body' => 'required|string',
'cc' => 'nullable|array',
'bcc' => 'nullable|array',
]);
} elseif ($type === Conversation::TYPE_PHONE) {
// Phone conversation.
$validator = Validator::make($request->all(), [
'name' => 'required|string',
'subject' => 'required|string|max:998',
'body' => 'required|string',
'phone' => 'nullable|string',
'to_email' => 'nullable|string',
]);
} elseif ($type === Conversation::TYPE_CUSTOM) {
$validation_rules = \Eventy::filter('conversation.custom.validation_rules', [
'body' => 'required|string',
'cc' => 'nullable|array',
'bcc' => 'nullable|array',
], $request);
$validator = Validator::make($request->all(), $validation_rules);
}
} else {
$validator = Validator::make($request->all(), [
'body' => 'required|string',
'cc' => 'nullable|array',
'bcc' => 'nullable|array',
]);
}
if ($validator->fails()) {
foreach ($validator->errors()->getMessages()as $errors) {
foreach ($errors as $field => $message) {
$response['msg'] .= $message.' ';
}
}
}
}
$body = $request->body;
// Replace base64 images with attachment URLs in case text
// was copy and pasted into the editor.
// https://github.com/freescout-helpdesk/freescout/issues/3057
$body = Thread::replaceBase64ImagesWithAttachments($body);
// List of emails.
$to_array = [];
if ($is_forward) {
$to_array = Conversation::sanitizeEmails($request->to_email);
} else {
$to_array = Conversation::sanitizeEmails($request->to);
}
// Check To
if (! $response['msg'] && $new && ! $is_phone && ! $is_custom) {
if (!$to_array) {
$response['msg'] .= __('Incorrect recipients');
}
}
// Check max. message size.
if (!$response['msg']) {
$max_message_size = (int)config('app.max_message_size');
if ($max_message_size) {
// Todo: take into account conversation history.
$message_size = mb_strlen($body, '8bit');
// Calculate attachments size.
$attachments_ids = array_merge($request->attachments ?? [], $request->embeds ?? []);
$attachments_ids = $this->decodeAttachmentsIds($attachments_ids);
if (count($attachments_ids)) {
$attachments_to_check = Attachment::select('size')->whereIn('id', $attachments_ids)->get();
foreach ($attachments_to_check as $attachment) {
$message_size += (int)$attachment->size;
}
}
if ($message_size > $max_message_size*1024*1024) {
$response['msg'] = __('Message is too large — :info. Please shorten your message or remove some attachments.', ['info' => __('Max. Message Size').': '.$max_message_size.' MB']);
}
}
}
if (!$response['msg']) {
// Get attachments info
// Delete removed attachments.
$attachments_info = $this->processReplyAttachments($request);
// Determine redirect.
// Must be done before updating current conversation's status or assignee.
// Redirect URL for new no saved yet conversation is determined below.
if (!$new) {
$response['redirect_url'] = $this->getRedirectUrl($request, $conversation, $user);
}
// Conversation
$now = date('Y-m-d H:i:s');
$status_changed = false;
$user_changed = false;
// Chat conversations in chat mode can not be undone.
$can_undo = true;
$request_status = (int)$request->status;
if ($new) {
// New conversation
$conversation = new Conversation();
$conversation->type = $type;
$conversation->subject = $request->subject;
$conversation->setPreview($body);
$conversation->mailbox_id = $request->mailbox_id;
$conversation->created_by_user_id = auth()->user()->id;
$conversation->source_via = Conversation::PERSON_USER;
$conversation->source_type = Conversation::SOURCE_TYPE_WEB;
} else {
// Reply or note
if ($request_status && $request_status != (int)$conversation->status) {
$status_changed = true;
}
if (!empty($request->subject)) {
$conversation->subject = $request->subject;
}
// When switching from regular message to phone and message sent
// without saving a draft type need to be saved here.
// Or vise versa.
if (($conversation->type == Conversation::TYPE_EMAIL && $type == Conversation::TYPE_PHONE)
|| ($conversation->type == Conversation::TYPE_PHONE && $type == Conversation::TYPE_EMAIL)
) {
$conversation->type = $type;
}
// Allow to convert phone conversations into email conversations.
if ($conversation->isPhone() && !$is_note && $conversation->customer
&& $customer_email = $conversation->customer->getMainEmail()
) {
$conversation->type = Conversation::TYPE_EMAIL;
$conversation->customer_email = $customer_email;
$is_phone = false;
}
}
if ($attachments_info['has_attachments']) {
$conversation->has_attachments = true;
}
// Customer can be empty in existing conversation if this is a draft.
$customer_email = '';
$customer = null;
if ($is_phone && $is_create) {
// Phone.
$phone_customer_data = $this->processPhoneCustomer($request);
$customer_email = $phone_customer_data['customer_email'];
$customer = $phone_customer_data['customer'];
if (! $conversation->customer_id) {
$conversation->customer_id = $customer->id;
}
} elseif ($is_custom) {
// No customer for custom conversations.
} else {
// Email or reply to a phone conversation.
if (!empty($to_array)) {
$customer_email = $to_array[0];
} elseif (!$conversation->customer_email
&& ($conversation->isEmail() || $conversation->isPhone())
&& $conversation->customer_id
&& $conversation->customer
) {
// When replying to a phone conversation, we need to
// set 'customer_email' for the conversation.
$customer_email = $conversation->customer->getMainEmail();
}
if (!$conversation->customer_id) {
// The new Customer must be linked to a specific Mailbox.
$data = [];
$data['mailbox_id'] = $conversation->mailbox_id;
$customer = Customer::create($customer_email, $data);
$conversation->customer_id = $customer->id;
} else {
$customer = $conversation->customer;
}
}
if ($customer_email && !$is_note && !$is_forward) {
$conversation->customer_email = $customer_email;
}
$prev_status = $conversation->status;
$conversation->status = $request_status ?: $conversation->status;
if (($prev_status != $conversation->status || $is_create)
&& $conversation->status == Conversation::STATUS_CLOSED
) {
$conversation->closed_by_user_id = $user->id;
$conversation->closed_at = date('Y-m-d H:i:s');
}
// We need to set state, as it may have been a draft.
$prev_state = $conversation->state;
$conversation->state = Conversation::STATE_PUBLISHED;
// Set assignee
$prev_user_id = $conversation->user_id;
if ((int) $request->user_id != -1) {
// Check if user has access to the current mailbox
if ((int) $conversation->user_id != (int) $request->user_id && $mailbox->userHasAccess($request->user_id)) {
$conversation->user_id = $request->user_id;
$user_changed = true;
}
} else {
$conversation->user_id = null;
}
// To is a single email string.
$to = '';
// List of emails.
$to_list = [];
if ($is_forward) {
if (empty($request->to_email[0])) {
$response['msg'] = __('Please specify a recipient.');
return \Response::json($response);
}
$to = $request->to_email[0];
} else {
if (!empty($request->to)) {
// When creating a new conversation, to is a list of emails.
if (is_array($request->to)) {
$to = $request->to[0];
} else {
$to = $request->to;
}
} else {
$to = $conversation->customer_email;
}
}
if (!$is_note && !$is_forward) {
// Save extra recipients to CC
if ($is_create && !$is_multiple && count($to_array) > 1) {
$conversation->setCc(array_merge(Conversation::sanitizeEmails($request->cc), $to_array));
} else {
if (!$is_multiple) {
$conversation->setCc(array_merge(Conversation::sanitizeEmails($request->cc), [$to]));
} else {
$conversation->setCc(Conversation::sanitizeEmails($request->cc));
}
}
$conversation->setBcc($request->bcc);
$conversation->last_reply_at = $now;
$conversation->last_reply_from = Conversation::PERSON_USER;
$conversation->user_updated_at = $now;
}
if ($conversation->isPhone() && $is_note) {
$conversation->last_reply_at = $now;
$conversation->last_reply_from = Conversation::PERSON_USER;
}
$conversation->updateFolder();
if ($from_draft) {
// Increment number of replies in conversation
$conversation->threads_count++;
// We need to set preview here as when conversation is created from draft,
// ThreadObserver::created() method is not called.
$conversation->setPreview($body);
}
$conversation->save();
// Redirect URL for new not saved yet conversation must be determined here.
if ($new) {
$response['redirect_url'] = $this->getRedirectUrl($request, $conversation, $user);
}
// Fire events
\Eventy::action('conversation.send_reply_save', $conversation, $request);
if (!$new) {
if ($status_changed) {
event(new ConversationStatusChanged($conversation));
\Eventy::action('conversation.status_changed', $conversation, $user, $changed_on_reply = true, $prev_status);
}
if ($user_changed) {
event(new ConversationUserChanged($conversation, $user));
\Eventy::action('conversation.user_changed', $conversation, $user, $prev_user_id);
}
}
if ($conversation->state != $prev_state) {
\Eventy::action('conversation.state_changed', $conversation, $user, $prev_state);
}
// Create thread
if (!$thread) {
$thread = new Thread();
$thread->conversation_id = $conversation->id;
if ($is_note || $is_forward) {
$thread->type = Thread::TYPE_NOTE;
} else {
$thread->type = Thread::TYPE_MESSAGE;
}
$thread->source_via = Thread::PERSON_USER;
$thread->source_type = Thread::SOURCE_TYPE_WEB;
} else {
if ($is_forward || $is_phone) {
$thread->type = Thread::TYPE_NOTE;
} else {
$thread->type = Thread::TYPE_MESSAGE;
}
$thread->created_at = $now;
}
if ($new) {
$thread->first = true;
}
$thread->user_id = $conversation->user_id;
$thread->status = $request_status ?? $conversation->status;
$thread->state = Thread::STATE_PUBLISHED;
if (!$is_custom) {
$thread->customer_id = $customer->id;
}
$thread->created_by_user_id = auth()->user()->id;
$thread->edited_by_user_id = null;
$thread->edited_at = null;
$thread->body = $body;
if ($is_create && !$is_multiple && count($to_array) > 1) {
$thread->setTo($to_array);
} else {
$thread->setTo($to);
}
// We save CC and BCC as is and filter emails when sending replies
$thread->setCc($request->cc);
$thread->setBcc($request->bcc);
if ($attachments_info['has_attachments'] && !$is_forward) {
$thread->has_attachments = true;
}
if (!empty($request->saved_reply_id)) {
$thread->saved_reply_id = $request->saved_reply_id;
}
$forwarded_conversations = [];
$forwarded_threads = [];
if ($is_forward) {
// Create forwarded conversations.
foreach ($to_array as $recipient_email) {
$forwarded_conversation = $conversation->replicate();
$forwarded_conversation->type = Conversation::TYPE_EMAIL;
$forwarded_conversation->setPreview($thread->body);
$forwarded_conversation->created_by_user_id = auth()->user()->id;
$forwarded_conversation->source_via = Conversation::PERSON_USER;
$forwarded_conversation->source_type = Conversation::SOURCE_TYPE_WEB;
$forwarded_conversation->threads_count = 0; // Counter will be incremented in ThreadObserver.
$forwarded_customer = Customer::create($recipient_email);
$forwarded_conversation->customer_id = $forwarded_customer->id;
// Reload customer object, otherwise it stores previous customer.
$forwarded_conversation->load('customer');
$forwarded_conversation->customer_email = $recipient_email;
$forwarded_conversation->subject = 'Fwd: '.$forwarded_conversation->subject;
//$forwarded_conversation->setCc(array_merge(Conversation::sanitizeEmails($request->cc), [$to]));
$forwarded_conversation->setCc(Conversation::sanitizeEmails($request->cc));
$forwarded_conversation->setBcc($request->bcc);
$forwarded_conversation->last_reply_at = $now;
$forwarded_conversation->last_reply_from = Conversation::PERSON_USER;
$forwarded_conversation->user_updated_at = $now;
if ($attachments_info['has_attachments']) {
$forwarded_conversation->has_attachments = true;
}
$forwarded_conversation->updateFolder();
$forwarded_conversation->save();
$forwarded_thread = $thread->replicate();
$forwarded_conversations[] = $forwarded_conversation;
$forwarded_threads[] = $forwarded_thread;
}
// Set forwarding meta data.
// todo: store array of numbers and IDs.
$thread->subtype = Thread::SUBTYPE_FORWARD;
$thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_NUMBER, $forwarded_conversation->number);
$thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_ID, $forwarded_conversation->id);
}
// Conversation history.
if (!empty($request->conv_history)) {
if ($request->conv_history != 'global') {
if ($is_forward && !empty($forwarded_threads)) {
foreach ($forwarded_threads as $forwarded_thread) {
$forwarded_thread->setMeta(Thread::META_CONVERSATION_HISTORY, $request->conv_history);
}
} else {
$thread->setMeta(Thread::META_CONVERSATION_HISTORY, $request->conv_history);
}
}
}
// From (mailbox alias).
if (!empty($request->from_alias)) {
$thread->from = $request->from_alias;
}
\Eventy::action('thread.before_save_from_request', $thread, $request);
$thread->save();
// Save forwarded thread.
if ($is_forward) {
foreach ($forwarded_conversations as $i => $forwarded_conversation) {
$forwarded_thread = $forwarded_threads[$i];
$forwarded_thread->conversation_id = $forwarded_conversation->id;
$forwarded_thread->type = Thread::TYPE_MESSAGE;
$forwarded_thread->subtype = null;
if ($attachments_info['has_attachments']) {
$forwarded_thread->has_attachments = true;
}
$forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_CONVERSATION_NUMBER, $conversation->number);
$forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_CONVERSATION_ID, $conversation->id);
$forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_THREAD_ID, $thread->id);
\Eventy::action('send_reply.before_save_forwarded_thread', $forwarded_thread, $request);
$forwarded_thread->save();
}
}
// If thread has been created from draft, remove the draft
// if ($request->thread_id) {
// $draft_thread = Thread::find($request->thread_id);
// if ($draft_thread) {
// $draft_thread->delete();
// }
// }
if ($from_draft) {
// Remove conversation from drafts folder if needed
$conversation->maybeRemoveFromDrafts();
}
// Update folders counters
$conversation->mailbox->updateFoldersCounters();
$response['status'] = 'success';
// Set thread_id for uploaded attachments
if ($attachments_info['attachments']) {
if ($is_forward) {
// Copy attachments for each thread.
if (count($forwarded_threads) > 1) {
$attachments = Attachment::whereIn('id', $attachments_info['attachments'])->get();
}
foreach ($forwarded_threads as $i => $forwarded_thread) {
if ($i == 0) {
Attachment::whereIn('id', $attachments_info['attachments'])->update(['thread_id' => $forwarded_thread->id]);
} else {
foreach ($attachments as $attachment) {
$attachment->duplicate($forwarded_thread->id);
}
}
}
} else {
Attachment::whereIn('id', $attachments_info['attachments'])
->where('thread_id', null)
->update(['thread_id' => $thread->id]);
}
}
// Follow conversation if it's assigned to someone else.
if (!$is_create && !$new && !$is_forward && !$is_note
&& $conversation->user_id != $user->id
) {
$user->followConversation($conversation->id);
}
if ($conversation->isChat() && \Helper::isChatMode()) {
$can_undo = false;
}
// When user creates a new conversation it may be saved as draft first.
if ($is_create) {
// 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($this->getUndoTimeout($can_undo)));
} elseif ($is_forward) {
// Forward.
// Notifications to users not sent.
event(new UserAddedNote($conversation, $thread));
foreach ($forwarded_conversations as $i => $forwarded_conversation) {
$forwarded_thread = $forwarded_threads[$i];
// To send email with forwarded conversation.
event(new UserReplied($forwarded_conversation, $forwarded_thread));
\Eventy::action('conversation.user_forwarded_can_undo', $conversation, $thread, $forwarded_conversation, $forwarded_thread);
// After Conversation::UNDO_TIMOUT period trigger final event.
\Helper::backgroundAction('conversation.user_forwarded', [$conversation, $thread, $forwarded_conversation, $forwarded_thread], now()->addSeconds(Conversation::UNDO_TIMOUT));
}
} elseif ($is_note) {
// Note.
event(new UserAddedNote($conversation, $thread));
\Eventy::action('conversation.note_added', $conversation, $thread);
} else {
// Reply.
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($this->getUndoTimeout($can_undo)));
}
// Send new conversation separately to each customer.
if ($is_create && count($to_array) > 1 && $is_multiple) {
$prev_customers_ids = [];
foreach ($to_array as $i => $customer_email) {
// Skip first email, as conversation has already been created for it.
if ($i == 0) {
continue;
}
// Get customer by email.
$customer_tmp = Customer::getByEmail($customer_email);
// Skip same customers.
if ($customer_tmp && in_array($customer_tmp->id, $prev_customers_ids)) {
continue;
}
if (!$customer_tmp) {
$customer_tmp = Customer::create($customer_email);
}
$prev_customers_ids[] = $customer_tmp->id;
// Copy conversation and thread.
$conversation_copy = $conversation->replicate();
$thread_copy = $thread->replicate();
// Save conversation.
$conversation_copy->threads_count = 0;
$conversation_copy->customer_id = $customer_tmp->id;
// Reload customer, otherwise all recipients will have the same name.
$conversation_copy->load('customer');
$conversation_copy->customer_email = $customer_email;
$conversation_copy->has_attachments = $conversation->has_attachments;
$conversation_copy->push();
$thread_copy->conversation_id = $conversation_copy->id;
$thread_copy->customer_id = $customer_tmp->id;
$thread_copy->has_attachments = $conversation->has_attachments;
$thread_copy->setTo($customer_email);
// Reload the conversation, otherwise Thread observer will be
// increasing threads_count for the first conversation.
$thread_copy->load('conversation');
$thread_copy->push();
// Copy attachments.
if (!empty($attachments_info['attachments'])) {
$attachments = Attachment::whereIn('id', $attachments_info['attachments'])->get();
foreach ($attachments as $attachment) {
$attachment->duplicate($thread_copy->id);
}
}
// Events.
// todo: allow to undo all emails
event(new UserCreatedConversation($conversation_copy, $thread_copy));
\Eventy::action('conversation.created_by_user_can_undo', $conversation_copy, $thread_copy);
// After Conversation::UNDO_TIMOUT period trigger final event.
\Helper::backgroundAction('conversation.created_by_user', [$conversation_copy, $thread_copy], now()->addSeconds($this->getUndoTimeout($can_undo)));
}
}
// Compose flash message.
$show_view_link = true;
if (!empty($request->after_send) && $request->after_send == MailboxUser::AFTER_SEND_STAY) {
$show_view_link = false;
}
$flash_vars = ['%tag_start%' => '<strong>', '%tag_end%' => '</strong>', '%view_start%' => '&nbsp;<a href="'.$conversation->url().'">', '%a_end%' => '</a>&nbsp;', '%undo_start%' => '&nbsp;<a href="'.route('conversations.undo', ['thread_id' => $thread->id]).'" class="text-danger">'];
if ($is_phone) {
$flash_type = 'warning';
if ($show_view_link) {
$flash_text = __(':%tag_start%Conversation created:%tag_end% :%view_start%View:%a_end% or :%undo_start%Undo:%a_end%', $flash_vars);
} else {
$flash_text = '<strong>'.__('Conversation created').'</strong>';
}
} elseif ($is_custom) {
$flash_type = 'warning';
$identifier = \Eventy::filter('conversation.custom.identifier', __('Custom conversation'), $request);
if ($show_view_link) {
$flash_text = __(':%tag_start%' . $identifier . ' added:%tag_end% :%view_start%View:%a_end%', $flash_vars);
} else {
$flash_text = '<strong>'.__('%identifier% added',['%identifier%'=>$identifier]).'</strong>';
}
} elseif ($is_note) {
$flash_type = 'warning';
if ($show_view_link) {
$flash_text = __(':%tag_start%Note added:%tag_end% :%view_start%View:%a_end%', $flash_vars);
} else {
$flash_text = '<strong>'.__('Note added').'</strong>';
}
} else {
$flash_type = 'success';
if ($show_view_link) {
$flash_text = __(':%tag_start%Email Sent:%tag_end% :%view_start%View:%a_end% or :%undo_start%Undo:%a_end%', $flash_vars);
} else {
$flash_text = __(':%tag_start%Email Sent:%tag_end% :%undo_start%Undo:%a_end%', $flash_vars);
}
}
if ($can_undo) {
\Session::flash('flash_'.$flash_type.'_floating', $flash_text);
}
}
if ($response['status'] == 'error' && empty($response['msg'])) {
$response['msg'] = 'Unknown error occurred';
}
return \Response::json($response);
}
}