mirror of
https://gh.wpcy.net/https://github.com/rilustrisimo/freescout-support.git
synced 2026-04-24 13:42:18 +08:00
1636 lines
64 KiB
PHP
1636 lines
64 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Attachment;
|
||
use App\Conversation;
|
||
use App\Customer;
|
||
use App\Email;
|
||
use App\Events\ConversationCustomerChanged;
|
||
use App\Events\CustomerCreatedConversation;
|
||
use App\Events\CustomerReplied;
|
||
use App\Events\UserReplied;
|
||
use App\Mailbox;
|
||
use App\Misc\Mail;
|
||
use App\Option;
|
||
use App\SendLog;
|
||
use App\Subscription;
|
||
use App\Thread;
|
||
use App\User;
|
||
use Illuminate\Console\Command;
|
||
//use Webklex\IMAP\Client;
|
||
|
||
class FetchEmails extends Command
|
||
{
|
||
const FWD_AS_CUSTOMER_COMMAND = '@fwd';
|
||
|
||
const MAX_SLEEP = 500000;
|
||
|
||
/**
|
||
* The name and signature of the console command.
|
||
*
|
||
* --identifier parameter is used to kill fetch-emails command running for too long.
|
||
*
|
||
* @var string
|
||
*/
|
||
protected $signature = 'freescout:fetch-emails {--days=3} {--unseen=1} {--debug=0} {--identifier=dummy} {--mailboxes=0}';
|
||
|
||
/**
|
||
* The console command description.
|
||
*
|
||
* @var string
|
||
*/
|
||
protected $description = 'Fetch emails from mailboxes addresses';
|
||
|
||
/**
|
||
* Current mailbox.
|
||
*
|
||
* Used to process emails sent to multiple mailboxes.
|
||
*/
|
||
public $mailbox;
|
||
|
||
/**
|
||
* Used to process emails sent to multiple mailboxes.
|
||
*/
|
||
public $mailboxes;
|
||
|
||
public $extra_import = [];
|
||
|
||
/**
|
||
* Page size when requesting emails from mail server.
|
||
*/
|
||
const PAGE_SIZE = 300;
|
||
|
||
/**
|
||
* Create a new command instance.
|
||
*
|
||
* @return void
|
||
*/
|
||
public function __construct()
|
||
{
|
||
parent::__construct();
|
||
}
|
||
|
||
/**
|
||
* Execute the console command.
|
||
*
|
||
* @return mixed
|
||
*/
|
||
public function handle()
|
||
{
|
||
$now = time();
|
||
$successfully = true;
|
||
$debug = $this->option('debug');
|
||
Option::set('fetch_emails_last_run', $now);
|
||
|
||
if ($debug) {
|
||
\Config::set('imap.options.debug', true);
|
||
}
|
||
|
||
$this->line('['.date('Y-m-d H:i:s').'] Fetching '.($this->option('unseen') ? 'UNREAD' : 'ALL').' emails for the last '.$this->option('days').' days.');
|
||
|
||
if (Mailbox::getInProtocols() === Mailbox::$in_protocols) {
|
||
$this->mailboxes = Mailbox::get();
|
||
} else {
|
||
// Get active mailboxes with the default in_protocols
|
||
$this->mailboxes = Mailbox::whereIn('in_protocol', array_keys(Mailbox::$in_protocols))->get();
|
||
}
|
||
|
||
// https://github.com/freescout-helpdesk/freescout/issues/2563
|
||
// Add small delay between connections to avoid blocking by mail servers,
|
||
// especially when there many mailboxes.
|
||
// Microseconds: 1 second = 1 000 000 microseconds.
|
||
$sleep = 20000;
|
||
|
||
// Fetches specific mailboxes only, in case the corresponding id is greater than zero.
|
||
$mailboxIds = array_filter(
|
||
array_map(
|
||
'intval',
|
||
explode(',', $this->option('mailboxes'))
|
||
),
|
||
function ($mailboxId) {
|
||
return $mailboxId > 0;
|
||
}
|
||
);
|
||
|
||
foreach ($this->mailboxes as $mailbox) {
|
||
if (!$mailbox->isInActive()) {
|
||
continue;
|
||
}
|
||
if ($mailboxIds !== [] && !in_array($mailbox->id, $mailboxIds, true)) {
|
||
continue;
|
||
}
|
||
|
||
$sleep += 20000;
|
||
if ($sleep > self::MAX_SLEEP) {
|
||
$sleep = self::MAX_SLEEP;
|
||
}
|
||
|
||
$this->info('['.date('Y-m-d H:i:s').'] Mailbox: '.$mailbox->name);
|
||
|
||
$this->mailbox = $mailbox;
|
||
$this->extra_import = [];
|
||
|
||
$debug_log = '';
|
||
|
||
try {
|
||
$debug_log = $this->executeFetch($mailbox, $debug);
|
||
} catch (\Exception $e) {
|
||
// If mail server starts to block the connection
|
||
// (when there are many mailboxes for example),
|
||
// we increase connection sleep time and retry after sleep.
|
||
// https://github.com/freescout-help-desk/freescout/issues/4227
|
||
if (trim($e->getMessage()) == 'connection setup failed') {
|
||
$sleep += 500000;
|
||
|
||
usleep(self::MAX_SLEEP);
|
||
|
||
try {
|
||
$debug_log = $this->executeFetch($mailbox, $debug);
|
||
} catch (\Exception $e) {
|
||
$successfully = false;
|
||
$this->logError('Error: '.$e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')').')';
|
||
}
|
||
} else {
|
||
$successfully = false;
|
||
$this->logError('Error: '.$e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')').')';
|
||
}
|
||
}
|
||
|
||
if ($debug && $debug_log) {
|
||
$this->line($debug_log);
|
||
}
|
||
|
||
// Import emails sent to several mailboxes at once.
|
||
if (count($this->extra_import)) {
|
||
$this->line('['.date('Y-m-d H:i:s').'] Importing emails sent to several mailboxes at once: '.count($this->extra_import));
|
||
foreach ($this->extra_import as $i => $extra_import) {
|
||
$this->line('['.date('Y-m-d H:i:s').'] '.($i+1).') '.$extra_import['message']->getSubject());
|
||
$this->processMessage($extra_import['message'], $extra_import['message_id'], $extra_import['mailbox'], [], true);
|
||
}
|
||
}
|
||
|
||
usleep($sleep);
|
||
}
|
||
|
||
if ($successfully && count($this->mailboxes)) {
|
||
Option::set('fetch_emails_last_successful_run', $now);
|
||
}
|
||
|
||
// Middleware Terminate handler is not launched for commands,
|
||
// so we need to run processing subscription events manually
|
||
Subscription::processEvents();
|
||
|
||
$this->info('['.date('Y-m-d H:i:s').'] Fetching finished');
|
||
|
||
$this->extra_import = [];
|
||
$this->mailbox = null;
|
||
$this->mailboxes = [];
|
||
}
|
||
|
||
public function executeFetch($mailbox, $debug)
|
||
{
|
||
$debug_log = '';
|
||
|
||
if ($debug) {
|
||
ob_start();
|
||
}
|
||
|
||
$this->fetch($mailbox);
|
||
|
||
if ($debug) {
|
||
$debug_log = ob_get_contents();
|
||
ob_end_clean();
|
||
}
|
||
|
||
return $debug_log;
|
||
}
|
||
|
||
public function fetch($mailbox)
|
||
{
|
||
$no_charset = false;
|
||
|
||
$client = \MailHelper::getMailboxClient($mailbox);
|
||
|
||
// Connect to the Server.
|
||
try {
|
||
$client->connect();
|
||
} catch (\Exception $e) {
|
||
$error = $e->getMessage();
|
||
|
||
// POP3 uses LegacyProtocol.php
|
||
// https://github.com/freescout-helpdesk/freescout/issues/4060
|
||
if ($error && \Str::startsWith($error, 'Mailbox is empty')) {
|
||
$this->line('['.date('Y-m-d H:i:s').'] Fetched: 0');
|
||
return;
|
||
} else {
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
$folders = [];
|
||
|
||
// Fetch emails from custom IMAP folders.
|
||
//if ($mailbox->in_protocol == Mailbox::IN_PROTOCOL_IMAP) {
|
||
$imap_folders = $mailbox->getInImapFolders();
|
||
|
||
foreach ($imap_folders as $folder_name) {
|
||
$folder = null;
|
||
try {
|
||
$folder = \MailHelper::getImapFolder($client, $folder_name);
|
||
} catch (\Exception $e) {
|
||
// Just log error and continue.
|
||
$this->error('['.date('Y-m-d H:i:s').'] IMAP folder ('.$folder_name.') not found on the mail server: '.$e->getMessage());
|
||
}
|
||
|
||
if ($folder) {
|
||
$folders[] = $folder;
|
||
} else {
|
||
$this->line('['.date('Y-m-d H:i:s').'] IMAP folder not found on the mail server: '.$folder_name);
|
||
}
|
||
}
|
||
// try {
|
||
// //$folders = $client->getFolders();
|
||
// } catch (\Exception $e) {
|
||
// // Do nothing
|
||
// }
|
||
|
||
$unseen = \Eventy::filter('fetch_emails.unseen', $this->option('unseen'), $mailbox);
|
||
if ($unseen != $this->option('unseen')) {
|
||
$this->line('['.date('Y-m-d H:i:s').'] Fetching: '.($unseen ? 'UNREAD' : 'ALL'));
|
||
}
|
||
|
||
$page_size = (int)config('app.fetching_bunch_size');
|
||
foreach ($folders as $folder) {
|
||
$this->line('['.date('Y-m-d H:i:s').'] Folder: '.($folder->full_name ?? $folder->name));
|
||
|
||
// Requesting emails by bunches allows to fetch large amounts of emails
|
||
// without problems with memory.
|
||
$page = 0;
|
||
do {
|
||
// Get messages.
|
||
$last_error = '';
|
||
$messages = collect([]);
|
||
|
||
try {
|
||
$messages_query = $folder->query()->since(now()->subDays($this->option('days')))->leaveUnread();
|
||
if ($unseen) {
|
||
$messages_query->unseen();
|
||
}
|
||
if ($no_charset) {
|
||
$messages_query->setCharset(null);
|
||
}
|
||
$messages_query->limit($page_size, $page);
|
||
|
||
$messages = $messages_query->get();
|
||
|
||
if (method_exists($client, 'getLastError')) {
|
||
$last_error = $client->getLastError();
|
||
}
|
||
} catch (\Exception $e) {
|
||
$last_error = $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')'.')';
|
||
}
|
||
|
||
if ($last_error && stristr($last_error, 'The specified charset is not supported')) {
|
||
$errors_count = count($client->getErrors());
|
||
// Solution for MS mailboxes.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/176
|
||
$messages_query = $folder->query()->since(now()->subDays($this->option('days')))->leaveUnread()->setCharset(null);
|
||
if ($unseen) {
|
||
$messages_query->unseen();
|
||
}
|
||
$messages = $messages_query->get();
|
||
|
||
$no_charset = true;
|
||
if (count($client->getErrors()) > $errors_count) {
|
||
$last_error = $client->getLastError();
|
||
} else {
|
||
$last_error = null;
|
||
}
|
||
}
|
||
|
||
if ($last_error && !\Str::startsWith($last_error, 'Mailbox is empty')) {
|
||
// Throw exception for INBOX only
|
||
if ($folder->name == 'INBOX' && !$messages) {
|
||
throw new \Exception($last_error, 1);
|
||
} else {
|
||
$this->error('['.date('Y-m-d H:i:s').'] '.$last_error);
|
||
$this->logError('Folder: '.$folder->name.'; Error: '.$last_error);
|
||
}
|
||
}
|
||
|
||
$this->line('['.date('Y-m-d H:i:s').'] Fetched: '.count($messages));
|
||
|
||
$message_index = 1;
|
||
|
||
// We have to sort messages manually, as they can be in non-chronological order
|
||
$messages = $this->sortMessage($messages);
|
||
foreach ($messages as $message_id => $message) {
|
||
$this->line('['.date('Y-m-d H:i:s').'] '.$message_index.') '.$message->getSubject());
|
||
$message_index++;
|
||
|
||
$dest_mailbox = \Eventy::filter('fetch_emails.mailbox_to_save_message', $mailbox, $folder);
|
||
$this->processMessage($message, $message_id, $dest_mailbox, $this->mailboxes);
|
||
}
|
||
$page++;
|
||
} while (count($messages) == $page_size);
|
||
}
|
||
|
||
$client->disconnect();
|
||
}
|
||
|
||
public function processMessage($message, $message_id, $mailbox, $mailboxes, $extra = false)
|
||
{
|
||
try {
|
||
|
||
// From - $from is the plain text email.
|
||
$from = $message->getReplyTo();
|
||
|
||
if (!$from
|
||
// https://github.com/freescout-helpdesk/freescout/issues/3101
|
||
|| !($reply_to = $this->formatEmailList($from))
|
||
|| empty($reply_to[0])
|
||
|| preg_match('/^.+@unknown$/', $reply_to[0])
|
||
) {
|
||
$from = $message->getFrom();
|
||
}
|
||
// https://github.com/freescout-helpdesk/freescout/issues/2833
|
||
/*else {
|
||
// If this is an auto-responder do not use Reply-To as sender email.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/2826
|
||
$headers = $this->headerToStr($message->getHeader());
|
||
if (\MailHelper::isAutoResponder($headers)) {
|
||
$from = $message->getFrom();
|
||
}
|
||
}*/
|
||
|
||
if ($from) {
|
||
$from = $this->formatEmailList($from);
|
||
}
|
||
|
||
if (!$from) {
|
||
$this->logError('From is empty');
|
||
$this->setSeen($message, $mailbox);
|
||
return;
|
||
} else {
|
||
$from = $from[0];
|
||
}
|
||
|
||
// Message-ID can be empty.
|
||
// https://stackoverflow.com/questions/8513165/php-imap-do-emails-have-to-have-a-messageid
|
||
if (!$message_id) {
|
||
// Generate artificial Message-ID.
|
||
$message_id = \MailHelper::generateMessageId($from, $message->getRawBody());
|
||
$this->line('['.date('Y-m-d H:i:s').'] Message-ID is empty, generated artificial Message-ID: '.$message_id);
|
||
}
|
||
|
||
$duplicate_message_id = false;
|
||
|
||
// Special hack to allow threading into conversations Jira messages.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/2927
|
||
//
|
||
// Jira does not properly populate Reference / In-Reply-To headers.
|
||
// When Jira sends a reply the In-Reply-To header is set to:
|
||
// JIRA.$\{issue-id}.$\{issue-created-date-millis}@$\{host}
|
||
//
|
||
// If we see the first message of a ticket we change the Message-ID,
|
||
// so all follow-ups in the ticket are nicely threaded.
|
||
$jira_message_id = preg_replace('/^(JIRA\.\d+\.\d+)\..*(@Atlassian.JIRA)/', '\1\2', $message_id);
|
||
if ($jira_message_id != $message_id) {
|
||
if (!Thread::where('message_id', $jira_message_id)->exists()) {
|
||
$message_id = $jira_message_id;
|
||
}
|
||
}
|
||
|
||
if (!$extra) {
|
||
$duplicate_message_id = Thread::where('message_id', $message_id)->first();
|
||
}
|
||
|
||
// Mailbox has been mentioned in Bcc.
|
||
if (!$extra && $duplicate_message_id) {
|
||
|
||
$recipients = array_merge(
|
||
$this->formatEmailList($message->getTo()),
|
||
$this->formatEmailList($message->getCc())
|
||
);
|
||
|
||
if (!in_array(Email::sanitizeEmail($mailbox->email), $recipients)
|
||
// Make sure that previous email has been imported into other mailbox.
|
||
&& $duplicate_message_id->conversation
|
||
&& $duplicate_message_id->conversation->mailbox_id != $mailbox->id
|
||
) {
|
||
$extra = true;
|
||
$duplicate_message_id = null;
|
||
}
|
||
}
|
||
|
||
// Gnerate artificial Message-ID if importing same email into several mailboxes.
|
||
if ($extra) {
|
||
// Generate artificial Message-ID.
|
||
$message_id = \MailHelper::generateMessageId(strstr($message_id, '@') ? $message_id : $from, $mailbox->id.$message_id);
|
||
$this->line('['.date('Y-m-d H:i:s').'] Generated artificial Message-ID: '.$message_id);
|
||
}
|
||
|
||
// Check if message already fetched.
|
||
if ($duplicate_message_id) {
|
||
$this->line('['.date('Y-m-d H:i:s').'] Message with such Message-ID has been fetched before: '.$message_id);
|
||
$this->setSeen($message, $mailbox);
|
||
return;
|
||
}
|
||
|
||
// Detect prev thread
|
||
$is_reply = false;
|
||
$prev_thread = null;
|
||
$user_id = null;
|
||
$user = null; // for user reply only
|
||
$message_from_customer = true;
|
||
$in_reply_to = trim($message->getInReplyTo() ?? '', '<>');
|
||
$references = $message->getReferences();
|
||
$attachments = $message->getAttachments();
|
||
$html_body = '';
|
||
|
||
// Is it a bounce message
|
||
$is_bounce = false;
|
||
|
||
// Determine previous Message-ID
|
||
$prev_message_ids = array();
|
||
|
||
if ($references && !is_array($references)) {
|
||
$references = array_filter(preg_split('/[, <>]/', $references));
|
||
}
|
||
|
||
if ($in_reply_to) {
|
||
$prev_message_ids[] = $in_reply_to;
|
||
}
|
||
if ($references) {
|
||
// Find non-empty references
|
||
if (is_array($references)) {
|
||
foreach ($references as $reference) {
|
||
if (!empty(trim($reference))) {
|
||
$prev_message_ids[] = trim($reference);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Some mail service providers change Message-ID of the outgoing email,
|
||
// so we are passing Message-ID in marker in body.
|
||
$reply_prefixes = [
|
||
\MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION,
|
||
\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER,
|
||
\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY,
|
||
];
|
||
|
||
// Try to get previous message ID from marker in body.
|
||
$html_body = $message->getHTMLBody(false);
|
||
$marker_message_id = \MailHelper::fetchMessageMarkerValue($html_body);
|
||
if ($marker_message_id) {
|
||
$prev_message_ids[] = $marker_message_id;
|
||
}
|
||
|
||
// Bounce detection.
|
||
$bounced_message_id = null;
|
||
if ($message->hasAttachments()) {
|
||
// Detect bounce by attachment.
|
||
// Check all attachments.
|
||
foreach ($attachments as $attachment) {
|
||
if (!empty(Attachment::$types[$attachment->getType()]) && Attachment::$types[$attachment->getType()] == Attachment::TYPE_MESSAGE
|
||
) {
|
||
if (
|
||
// Checking the name will lead to mistakes if someone attaches a file with such name.
|
||
// Dashes are converted to space.
|
||
//in_array(strtoupper($attachment->getName()), ['RFC822', 'DELIVERY STATUS', 'DELIVERY STATUS NOTIFICATION', 'UNDELIVERED MESSAGE'])
|
||
preg_match('/delivery-status/', strtolower($attachment->content_type))
|
||
// 7.3.1 The Message/rfc822 (primary) subtype. A Content-Type of "message/rfc822" indicates that the body contains an encapsulated message, with the syntax of an RFC 822 message
|
||
//|| $attachment->content_type == 'message/rfc822'
|
||
) {
|
||
$is_bounce = true;
|
||
|
||
$this->line('['.date('Y-m-d H:i:s').'] Bounce detected by attachment content-type: '.$attachment->content_type);
|
||
|
||
// Try to get Message-ID of the original email.
|
||
if (!$bounced_message_id) {
|
||
//print_r(\MailHelper::parseHeaders($attachment->getContent()));
|
||
$bounced_message_id = \MailHelper::getHeader($attachment->getContent(), 'message_id');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
$message_header = $this->headerToStr($message->getHeader());
|
||
|
||
// Check Content-Type header.
|
||
if (!$is_bounce && $message_header) {
|
||
if (\MailHelper::detectBounceByHeaders($message_header)) {
|
||
$is_bounce = true;
|
||
}
|
||
}
|
||
// Check message's From field.
|
||
if (!$is_bounce) {
|
||
if ($message->getFrom()) {
|
||
$original_from = $this->formatEmailList($message->getFrom());
|
||
$original_from = $original_from[0];
|
||
$is_bounce = preg_match('/^mailer\-daemon@/i', $original_from);
|
||
if ($is_bounce) {
|
||
$this->line('['.date('Y-m-d H:i:s').'] Bounce detected by From header: '.$original_from);
|
||
}
|
||
}
|
||
}
|
||
// Check Return-Path header
|
||
if (!$is_bounce && preg_match("/^Return\-Path: <>/i", $message_header)) {
|
||
$this->line('['.date('Y-m-d H:i:s').'] Bounce detected by Return-Path header.');
|
||
$is_bounce = true;
|
||
}
|
||
|
||
if ($is_bounce && !$bounced_message_id) {
|
||
foreach ($attachments as $attachment_msg) {
|
||
// 7.3.1 The Message/rfc822 (primary) subtype. A Content-Type of "message/rfc822" indicates that the body contains an encapsulated message, with the syntax of an RFC 822 message
|
||
if ($attachment_msg->content_type == 'message/rfc822') {
|
||
$bounced_message_id = \MailHelper::getHeader($attachment_msg->getContent(), 'message_id');
|
||
if ($bounced_message_id) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# Try to get the thread traversing the possible prev_message_ids
|
||
foreach ($prev_message_ids as $prev_message_id) {
|
||
// Is it a message from Customer or User replied to the notification
|
||
preg_match('/^'.\MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION."\-(\d+)\-(\d+)\-/", $prev_message_id, $m);
|
||
|
||
if (!$is_bounce && !empty($m[1]) && !empty($m[2])) {
|
||
// Reply from User to the notification
|
||
$prev_thread = Thread::find($m[1]);
|
||
$user_id = $m[2];
|
||
$user = User::find($user_id);
|
||
$message_from_customer = false;
|
||
$is_reply = true;
|
||
|
||
if (!$user) {
|
||
$this->logError('User not found: '.$user_id);
|
||
$this->setSeen($message, $mailbox);
|
||
return;
|
||
}
|
||
// Skip auto-replies sent to the email notification on behalf of a user.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/4035
|
||
if (\MailHelper::isAutoResponder($message_header)) {
|
||
$this->logError('Skipping an auto-reply to the email notification');
|
||
$this->setSeen($message, $mailbox);
|
||
return;
|
||
}
|
||
$this->line('['.date('Y-m-d H:i:s').'] Message from: User');
|
||
} else {
|
||
// Message from Customer or User replied to his reply to notification
|
||
$this->line('['.date('Y-m-d H:i:s').'] Message from: Customer');
|
||
|
||
if (!$is_bounce) {
|
||
if ($prev_message_id) {
|
||
$prev_thread_id = '';
|
||
|
||
// Customer replied to the email from user
|
||
preg_match('/^'.\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER."\-(\d+)\-([a-z0-9]+)@/", $prev_message_id, $m);
|
||
// Simply checking thread_id from message_id was causing an issue when
|
||
// customer was sending a message from FreeScout - the message was
|
||
// connected to the wrong conversation.
|
||
if (!empty($m[1]) && !empty($m[2])) {
|
||
$message_id_hash = $m[2];
|
||
if (strlen($message_id_hash) == 16) {
|
||
if ($message_id_hash == \MailHelper::getMessageIdHash($m[1])) {
|
||
$prev_thread_id = $m[1];
|
||
}
|
||
} else {
|
||
// Backward compatibility.
|
||
$prev_thread_id = $m[1];
|
||
}
|
||
}
|
||
|
||
// Customer replied to the auto reply
|
||
if (!$prev_thread_id) {
|
||
preg_match('/^'.\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY."\-(\d+)\-([a-z0-9]+)@/", $prev_message_id, $m);
|
||
if (!empty($m[1]) && !empty($m[2])) {
|
||
$message_id_hash = $m[2];
|
||
if (strlen($message_id_hash) == 16) {
|
||
if ($message_id_hash == \MailHelper::getMessageIdHash($m[1])) {
|
||
$prev_thread_id = $m[1];
|
||
}
|
||
} else {
|
||
// Backward compatibility.
|
||
$prev_thread_id = $m[1];
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($prev_thread_id) {
|
||
$prev_thread = Thread::find($prev_thread_id);
|
||
} else {
|
||
// Customer replied to his own message
|
||
$prev_thread = Thread::where('message_id', $prev_message_id)->first();
|
||
}
|
||
|
||
// Reply from user to his reply to the notification
|
||
if (!$prev_thread
|
||
&& ($prev_thread = Thread::where('message_id', $prev_message_id)->first())
|
||
&& $prev_thread->created_by_user_id
|
||
&& $prev_thread->created_by_user->hasEmail($from)
|
||
) {
|
||
$user_id = $user->id;
|
||
$message_from_customer = false;
|
||
$is_reply = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# If a thread is found, we keep it and break
|
||
if (!empty($prev_thread)) {
|
||
$is_reply = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Make sure that prev_thread belongs to the current mailbox.
|
||
// Problems may arise when forwarding conversation for example.
|
||
//
|
||
// For replies to email notifications it's allowed to have prev_thread in
|
||
// another mailbox as conversation can be moved.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/3455
|
||
if ($prev_thread && $message_from_customer) {
|
||
if ($prev_thread->conversation->mailbox_id != $mailbox->id) {
|
||
// https://github.com/freescout-helpdesk/freescout/issues/2807
|
||
// Behaviour of email sent to multiple mailboxes:
|
||
// If a user from either mailbox replies, then a new conversation is created
|
||
// in the other mailbox with another new conversation ID.
|
||
//
|
||
// Try to get thread by generated message ID.
|
||
if ($in_reply_to) {
|
||
$prev_thread = Thread::where('message_id', \MailHelper::generateMessageId($in_reply_to, $mailbox->id.$in_reply_to))->first();
|
||
|
||
if (!$prev_thread) {
|
||
$prev_thread = null;
|
||
$is_reply = false;
|
||
}
|
||
} else {
|
||
$prev_thread = null;
|
||
$is_reply = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get body
|
||
if (!$html_body) {
|
||
// Get body and do not replace :cid with images base64
|
||
$html_body = $message->getHTMLBody(false);
|
||
}
|
||
|
||
$is_html = true;
|
||
|
||
if ($html_body) {
|
||
$body = $html_body;
|
||
} else {
|
||
$is_html = false;
|
||
$body = $message->getTextBody() ?? '';
|
||
$body = htmlspecialchars($body);
|
||
}
|
||
|
||
// We have to fetch absolutely all emails, even with empty body.
|
||
// if (!$body) {
|
||
// $this->logError('Message body is empty');
|
||
// $this->setSeen($message, $mailbox);
|
||
// continue;
|
||
// }
|
||
|
||
// Webklex/php-imap returns object instead of a string.
|
||
$subject = $message->getSubject()."";
|
||
|
||
// Convert subject encoding
|
||
if (preg_match('/=\?[a-z\d-]+\?[BQ]\?.*\?=/i', $subject)) {
|
||
$subject = iconv_mime_decode($subject, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8');
|
||
}
|
||
|
||
$to = $this->formatEmailList($message->getTo());
|
||
|
||
$cc = $this->formatEmailList($message->getCc());
|
||
|
||
// It will always return an empty value as it's Bcc.
|
||
$bcc = $this->formatEmailList($message->getBcc());
|
||
|
||
// If existing user forwarded customer's email to the mailbox
|
||
// we are creating a new conversation as if it was sent by the customer.
|
||
if (// Some mail clients to not add "In-Reply-To" header when forwarding emails.
|
||
// https://github.com/freescout-help-desk/freescout/issues/4348
|
||
//$in_reply_to
|
||
// We should use body here, as entire HTML may contain
|
||
// email looking things.
|
||
//&& ($fwd_body = $html_body ?: $message->getTextBody())
|
||
$body
|
||
//&& preg_match("/^(".implode('|', \MailHelper::$fwd_prefixes)."):(.*)/i", $subject, $m)
|
||
// F:, FW:, FWD:, WG:, De:
|
||
&& preg_match("/^[[:alpha:]]{1,3}:(.*)/i", $subject, $m)
|
||
// It can be just "Fwd:"
|
||
//&& !empty($m[1])
|
||
&& !$user_id && !$is_reply && !$prev_thread
|
||
// Only if the email has been sent to one mailbox.
|
||
&& count($to) == 1 && count($cc) == 0
|
||
// We need to replace also any potential <style></style> tags.
|
||
&& preg_match("/^[\s]*".self::FWD_AS_CUSTOMER_COMMAND."/su", strtolower(trim(\Helper::stripTags($body))))
|
||
) {
|
||
// Try to get "From:" from body.
|
||
$original_sender = $this->getOriginalSenderFromFwd($body);
|
||
|
||
if ($original_sender) {
|
||
// Check if sender is the existing user.
|
||
$sender_is_user = User::nonDeleted()->where('email', $from)->exists();
|
||
|
||
if ($sender_is_user) {
|
||
// Substitute sender.
|
||
$from = $original_sender;
|
||
$subject = trim($m[1] ?? $subject);
|
||
$message_from_customer = true;
|
||
|
||
// Remove @fwd from body.
|
||
$body = trim(preg_replace("/".self::FWD_AS_CUSTOMER_COMMAND."([\s<]+)/su", '$1', $body));
|
||
}
|
||
}
|
||
}
|
||
|
||
// separateReply() function may distort original HTML if email
|
||
// is mentioned as <test@example.org> and it will interpret it as a tag.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/4036
|
||
|
||
$body = $this->separateReply($body, $is_html, $is_reply, !$message_from_customer, (($message_from_customer && $prev_thread) ? $prev_thread->getMessageId($mailbox) : ''));
|
||
|
||
// Create customers
|
||
$emails = array_merge(
|
||
$this->attrToArray($message->getFrom()),
|
||
$this->attrToArray($message->getReplyTo()),
|
||
$this->attrToArray($message->getTo()),
|
||
$this->attrToArray($message->getCc()),
|
||
// It will always return an empty value as it's Bcc.
|
||
$this->attrToArray($message->getBcc())
|
||
);
|
||
$this->createCustomers($emails, $mailbox->getEmails());
|
||
|
||
$date = $this->attrToDate($message->getDate());
|
||
|
||
if ($date) {
|
||
$app_timezone = config('app.timezone');
|
||
if ($app_timezone) {
|
||
$date->setTimezone($app_timezone);
|
||
}
|
||
}
|
||
$now = now();
|
||
if (!$date || $date->greaterThan($now)) {
|
||
$date = $now;
|
||
}
|
||
|
||
$data = \Eventy::filter('fetch_emails.data_to_save', [
|
||
'mailbox' => $mailbox,
|
||
'message_id' => $message_id,
|
||
'prev_thread' => $prev_thread,
|
||
'from' => $from,
|
||
'to' => $to,
|
||
'cc' => $cc,
|
||
'bcc' => $bcc,
|
||
'subject' => $subject,
|
||
'body' => $body,
|
||
'attachments' => $attachments,
|
||
'message' => $message,
|
||
'is_bounce' => $is_bounce,
|
||
'message_from_customer' => $message_from_customer,
|
||
'user' => $user,
|
||
'date' => $date,
|
||
]);
|
||
|
||
$new_thread = null;
|
||
if ($message_from_customer) {
|
||
|
||
// We should import the message into other mailboxes even if previous thread is set.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/3473
|
||
//if (!$data['prev_thread']) {
|
||
|
||
// Maybe this email need to be imported also into other mailbox.
|
||
|
||
$recipient_emails = array_unique($this->formatEmailList(array_merge(
|
||
$this->attrToArray($message->getTo()),
|
||
$this->attrToArray($message->getCc()),
|
||
// It will always return an empty value as it's Bcc.
|
||
$this->attrToArray($message->getBcc())
|
||
)));
|
||
|
||
if (count($mailboxes) && count($recipient_emails) > 1) {
|
||
foreach ($mailboxes as $check_mailbox) {
|
||
if ($check_mailbox->id == $mailbox->id) {
|
||
continue;
|
||
}
|
||
if (!$check_mailbox->isInActive()) {
|
||
continue;
|
||
}
|
||
foreach ($recipient_emails as $recipient_email) {
|
||
// No need to check mailbox aliases.
|
||
if (\App\Email::sanitizeEmail($check_mailbox->email) == $recipient_email) {
|
||
$this->extra_import[] = [
|
||
'mailbox' => $check_mailbox,
|
||
'message' => $message,
|
||
'message_id' => $message_id,
|
||
];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
//}
|
||
|
||
if (\Eventy::filter('fetch_emails.should_save_thread', true, $data) !== false) {
|
||
// SendAutoReply listener will check bounce flag and will not send an auto reply if this is an auto responder.
|
||
$new_thread = $this->saveCustomerThread($mailbox, $data['message_id'], $data['prev_thread'], $data['from'], $data['to'], $data['cc'], $data['bcc'], $data['subject'], $data['body'], $data['attachments'], $data['message']->getHeader(), $data['date']);
|
||
} else {
|
||
$this->line('['.date('Y-m-d H:i:s').'] Hook fetch_emails.should_save_thread returned false. Skipping message.');
|
||
$this->setSeen($message, $mailbox);
|
||
return;
|
||
}
|
||
} else {
|
||
// Check if From is the same as user's email.
|
||
// If not we send an email with information to the sender.
|
||
if (!$user->hasEmail($from)) {
|
||
$this->logError("Sender address {$from} does not match ".$user->getFullName()." user email: ".$user->email.". Add ".$user->email." to user's Alternate Emails in the users's profile to allow the user reply from this address.");
|
||
$this->setSeen($message, $mailbox);
|
||
|
||
// Send "Unable to process your update email" to user
|
||
\App\Jobs\SendEmailReplyError::dispatch($from, $user, $mailbox)->onQueue('emails');
|
||
|
||
return;
|
||
}
|
||
|
||
// Save user thread only if there prev_thread is set.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/3455
|
||
if (!$prev_thread) {
|
||
$this->logError("Support agent's reply to the email notification could not be processed as previous thread could not be determined.");
|
||
$this->setSeen($message, $mailbox);
|
||
|
||
return;
|
||
}
|
||
|
||
if (\Eventy::filter('fetch_emails.should_save_thread', true, $data) !== false) {
|
||
$new_thread = $this->saveUserThread($data['mailbox'], $data['message_id'], $data['prev_thread'], $data['user'], $data['from'], $data['to'], $data['cc'], $data['bcc'], $data['body'], $data['attachments'], $data['message']->getHeader(), $data['date']);
|
||
} else {
|
||
$this->line('['.date('Y-m-d H:i:s').'] Hook fetch_emails.should_save_thread returned false. Skipping message.');
|
||
$this->setSeen($message, $mailbox);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if ($new_thread) {
|
||
$this->setSeen($message, $mailbox);
|
||
$this->line('['.date('Y-m-d H:i:s').'] Thread successfully created: '.$new_thread->id);
|
||
|
||
// If it was a bounce message, save bounce data.
|
||
if ($message_from_customer && $is_bounce) {
|
||
$this->saveBounceData($new_thread, $bounced_message_id, $from);
|
||
}
|
||
} else {
|
||
$this->logError('Error occurred processing message');
|
||
}
|
||
} catch (\Exception $e) {
|
||
$this->setSeen($message, $mailbox);
|
||
$this->logError(\Helper::formatException($e));
|
||
}
|
||
}
|
||
|
||
// Try to get "From:" from body.
|
||
public function getOriginalSenderFromFwd($body)
|
||
{
|
||
// https://github.com/freescout-helpdesk/freescout/issues/2672
|
||
$body = preg_replace("/[\"']cid:/", '!', $body);
|
||
// Cut out the command, otherwise it will be recognized as an email.
|
||
$body = preg_replace("/".self::FWD_AS_CUSTOMER_COMMAND."([\s<]+)/isu", '$1', $body);
|
||
|
||
// Looks like email texts may appear in attributes:
|
||
// https://github.com/freescout-helpdesk/freescout/issues/276
|
||
// - :test@example.org
|
||
// - <test@example.org>
|
||
// - <test@example.org>
|
||
|
||
preg_match("/[\"'<:;]([^\"'<:;!@\s]+@[^\"'>:&@\s]+)[\"'>:&]/", $body, $b);
|
||
|
||
$email = $b[1] ?? '';
|
||
// https://github.com/freescout-helpdesk/freescout/issues/2517
|
||
$email = preg_replace("#.*<(.*)>.*#", "$1", $email);
|
||
|
||
return Email::sanitizeEmail($email);
|
||
}
|
||
|
||
public function saveBounceData($new_thread, $bounced_message_id, $from)
|
||
{
|
||
// Try to find bounced thread by Message-ID.
|
||
$bounced_thread = null;
|
||
if ($bounced_message_id) {
|
||
$prefixes = [
|
||
\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER,
|
||
\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY,
|
||
];
|
||
preg_match('/^('.implode('|', $prefixes).')\-(\d+)\-/', $bounced_message_id, $matches);
|
||
if (!empty($matches[2])) {
|
||
$bounced_thread = Thread::find($matches[2]);
|
||
}
|
||
}
|
||
|
||
$status_data = [
|
||
'is_bounce' => true,
|
||
];
|
||
if ($bounced_thread) {
|
||
$status_data['bounce_for_thread'] = $bounced_thread->id;
|
||
$status_data['bounce_for_conversation'] = $bounced_thread->conversation_id;
|
||
}
|
||
|
||
$new_thread->updateSendStatusData($status_data);
|
||
$new_thread->save();
|
||
|
||
// Update status of the original message and create log record.
|
||
if ($bounced_thread) {
|
||
$bounced_thread->send_status = SendLog::STATUS_DELIVERY_ERROR;
|
||
|
||
$status_data = [
|
||
'bounced_by_thread' => $new_thread->id,
|
||
'bounced_by_conversation' => $new_thread->conversation_id,
|
||
// todo.
|
||
// 'bounce_info' => [
|
||
// ]
|
||
];
|
||
|
||
$bounced_thread->updateSendStatusData($status_data);
|
||
$bounced_thread->save();
|
||
|
||
// Bounces can be soft and hard, for now log both as STATUS_DELIVERY_ERROR.
|
||
SendLog::log($bounced_thread->id, null, $from, SendLog::MAIL_TYPE_EMAIL_TO_CUSTOMER, SendLog::STATUS_DELIVERY_ERROR, $bounced_thread->created_by_customer_id, null, 'Message bounced');
|
||
}
|
||
}
|
||
|
||
public function logError($message)
|
||
{
|
||
$this->error('['.date('Y-m-d H:i:s').'] '.$message);
|
||
|
||
$mailbox_name = '';
|
||
if ($this->mailbox) {
|
||
$mailbox_name = $this->mailbox->name;
|
||
}
|
||
|
||
try {
|
||
activity()
|
||
->withProperties([
|
||
'error' => $message,
|
||
'mailbox' => $mailbox_name,
|
||
])
|
||
->useLog(\App\ActivityLog::NAME_EMAILS_FETCHING)
|
||
->log(\App\ActivityLog::DESCRIPTION_EMAILS_FETCHING_ERROR);
|
||
} catch (\Exception $e) {
|
||
// Do nothing
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save email from customer as thread.
|
||
*/
|
||
public function saveCustomerThread($mailbox, $message_id, $prev_thread, $from, $to, $cc, $bcc, $subject, $body, $attachments, $headers, $date)
|
||
{
|
||
// Fetch date & time setting.
|
||
$use_mail_date_on_fetching = config('app.use_mail_date_on_fetching');
|
||
|
||
// Find conversation.
|
||
$new = false;
|
||
$conversation = null;
|
||
$prev_customer_id = null;
|
||
if ($use_mail_date_on_fetching) {
|
||
$now = $date;
|
||
}else{
|
||
$now = date('Y-m-d H:i:s');
|
||
}
|
||
$conv_cc = $cc;
|
||
$prev_conv_cc = $conv_cc;
|
||
|
||
// Customers are created before with email and name
|
||
$customer = Customer::create($from);
|
||
if ($prev_thread) {
|
||
$conversation = $prev_thread->conversation;
|
||
|
||
// If reply came from another customer: change customer, add original as CC.
|
||
// If FreeScout will not change the customer, the reply will be shown
|
||
// as coming from the original customer (not the real sender) and cause confusion.
|
||
// Below after events are fired we roll customer back.
|
||
if ($conversation->customer_id != $customer->id) {
|
||
$prev_customer_id = $conversation->customer_id;
|
||
$prev_customer_email = $conversation->customer_email;
|
||
|
||
// Do not add to CC emails from the original's BCC
|
||
if (!in_array($conversation->customer_email, $conversation->getBccArray())) {
|
||
$conv_cc[] = $conversation->customer_email;
|
||
}
|
||
$conversation->customer_id = $customer->id;
|
||
}
|
||
} else {
|
||
// Create conversation
|
||
$new = true;
|
||
|
||
$conversation = new Conversation();
|
||
$conversation->type = Conversation::TYPE_EMAIL;
|
||
$conversation->state = Conversation::STATE_PUBLISHED;
|
||
$conversation->subject = $subject;
|
||
$conversation->setPreview($body);
|
||
$conversation->mailbox_id = $mailbox->id;
|
||
$conversation->customer_id = $customer->id;
|
||
$conversation->created_by_customer_id = $customer->id;
|
||
$conversation->source_via = Conversation::PERSON_CUSTOMER;
|
||
$conversation->source_type = Conversation::SOURCE_TYPE_EMAIL;
|
||
$conversation->created_at = $now;
|
||
}
|
||
|
||
$prev_has_attachments = $conversation->has_attachments;
|
||
// Update has_attachments only if email has attachments AND conversation hasn't has_attachments already set
|
||
// Prevent to set has_attachments value back to 0 if the new reply doesn't have any attachment
|
||
if (!$conversation->has_attachments && count($attachments)) {
|
||
// Later we will check which attachments are embedded.
|
||
$conversation->has_attachments = true;
|
||
}
|
||
|
||
// Save extra recipients to CC, but do not add the mailbox itself as a CC.
|
||
$conversation->setCc(array_merge($conv_cc, array_diff($to, $mailbox->getEmails())));
|
||
// BCC should keep BCC of the first email,
|
||
// so we change BCC only if it contains emails.
|
||
if ($bcc) {
|
||
$conversation->setBcc($bcc);
|
||
}
|
||
$conversation->customer_email = $from;
|
||
// Reply from customer makes conversation active
|
||
if ($conversation->status != Conversation::STATUS_ACTIVE) {
|
||
$conversation->status = \Eventy::filter('conversation.status_changing', Conversation::STATUS_ACTIVE, $conversation);
|
||
}
|
||
$conversation->last_reply_at = $now;
|
||
$conversation->last_reply_from = Conversation::PERSON_CUSTOMER;
|
||
// Reply from customer to deleted conversation should undelete it.
|
||
if ($conversation->state == Conversation::STATE_DELETED) {
|
||
$conversation->state = Conversation::STATE_PUBLISHED;
|
||
}
|
||
// Set folder id
|
||
$conversation->updateFolder();
|
||
$conversation->save();
|
||
|
||
// Thread
|
||
$thread = new Thread();
|
||
$thread->conversation_id = $conversation->id;
|
||
$thread->user_id = $conversation->user_id;
|
||
$thread->type = Thread::TYPE_CUSTOMER;
|
||
$thread->status = $conversation->status;
|
||
$thread->state = Thread::STATE_PUBLISHED;
|
||
$thread->message_id = $message_id;
|
||
$thread->headers = $this->headerToStr($headers);
|
||
$thread->body = $body;
|
||
$thread->from = $from;
|
||
$thread->setTo($to);
|
||
$thread->setCc($cc);
|
||
$thread->setBcc($bcc);
|
||
$thread->source_via = Thread::PERSON_CUSTOMER;
|
||
$thread->source_type = Thread::SOURCE_TYPE_EMAIL;
|
||
$thread->customer_id = $customer->id;
|
||
$thread->created_by_customer_id = $customer->id;
|
||
$thread->created_at = $now;
|
||
$thread->updated_at = $now;
|
||
if ($new) {
|
||
$thread->first = true;
|
||
}
|
||
try {
|
||
$thread->save();
|
||
} catch (\Exception $e) {
|
||
// Could not save thread.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/3186
|
||
if ($new) {
|
||
$conversation->deleteForever();
|
||
}
|
||
throw $e;
|
||
}
|
||
|
||
$body_changed = false;
|
||
$saved_attachments = $this->saveAttachments($attachments, $thread->id);
|
||
if ($saved_attachments) {
|
||
|
||
// After attachments saved to the disk we can replace cids in body (for PLAIN and HTML body)
|
||
$thread->body = $this->replaceCidsWithAttachmentUrls($thread->body, $saved_attachments, $conversation, $prev_has_attachments);
|
||
$body_changed = true;
|
||
|
||
foreach ($saved_attachments as $saved_attachment) {
|
||
if (!$saved_attachment['attachment']->embedded) {
|
||
$thread->has_attachments = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
$new_body = Thread::replaceBase64ImagesWithAttachments($thread->body);
|
||
if ($new_body != $thread->body) {
|
||
$thread->body = $new_body;
|
||
$body_changed = true;
|
||
}
|
||
|
||
if ($body_changed) {
|
||
$thread->save();
|
||
}
|
||
|
||
// Update conversation here if needed.
|
||
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
|
||
$conversation->mailbox->updateFoldersCounters();
|
||
|
||
if ($new) {
|
||
event(new CustomerCreatedConversation($conversation, $thread));
|
||
\Eventy::action('conversation.created_by_customer', $conversation, $thread, $customer);
|
||
} else {
|
||
event(new CustomerReplied($conversation, $thread));
|
||
\Eventy::action('conversation.customer_replied', $conversation, $thread, $customer);
|
||
}
|
||
|
||
// Conversation customer changed
|
||
// if ($prev_customer_id) {
|
||
// event(new ConversationCustomerChanged($conversation, $prev_customer_id, $prev_customer_email, null, $customer));
|
||
// }
|
||
|
||
// Return original customer back.
|
||
if ($prev_customer_id) {
|
||
$conversation->customer_id = $prev_customer_id;
|
||
$conversation->customer_email = $prev_customer_email;
|
||
$conversation->setCc(array_merge($prev_conv_cc, array_diff($to, $mailbox->getEmails())));
|
||
$conversation->save();
|
||
}
|
||
|
||
return $thread;
|
||
}
|
||
|
||
/**
|
||
* Save email reply from user as thread.
|
||
*/
|
||
public function saveUserThread($mailbox, $message_id, $prev_thread, $user, $from, $to, $cc, $bcc, $body, $attachments, $headers, $date)
|
||
{
|
||
// fetch time setting.
|
||
$use_mail_date_on_fetching = config('app.use_mail_date_on_fetching');
|
||
|
||
$conversation = null;
|
||
if ($use_mail_date_on_fetching) {
|
||
$now = $date;
|
||
}else{
|
||
$now = date('Y-m-d H:i:s');
|
||
}
|
||
$user_id = $user->id;
|
||
|
||
$conversation = $prev_thread->conversation;
|
||
// Determine assignee.
|
||
switch ($mailbox->ticket_assignee) {
|
||
case Mailbox::TICKET_ASSIGNEE_ANYONE:
|
||
$conversation->user_id = Conversation::USER_UNASSIGNED;
|
||
break;
|
||
case Mailbox::TICKET_ASSIGNEE_REPLYING_UNASSIGNED:
|
||
if (!$conversation->user_id) {
|
||
$conversation->user_id = $user_id;
|
||
}
|
||
break;
|
||
case Mailbox::TICKET_ASSIGNEE_REPLYING:
|
||
$conversation->user_id = $user_id;
|
||
break;
|
||
case Mailbox::TICKET_ASSIGNEE_KEEP_CURRENT:
|
||
// Do nothing.
|
||
break;
|
||
}
|
||
|
||
$prev_has_attachments = $conversation->has_attachments;
|
||
if (!$conversation->has_attachments && count($attachments)) {
|
||
// Later we will check which attachments are embedded.
|
||
$conversation->has_attachments = true;
|
||
}
|
||
|
||
// Save extra recipients to CC
|
||
$conv_cc = $conversation->getCcArray();
|
||
$conversation->setCc(array_merge($cc, $to));
|
||
$conversation->setBcc($bcc);
|
||
|
||
// Respect mailbox settings for "Status After Replying
|
||
$prev_status = $conversation->status;
|
||
$conversation->status = ($mailbox->ticket_status == Mailbox::TICKET_STATUS_KEEP_CURRENT ? $conversation->status : $mailbox->ticket_status);
|
||
if ($conversation->status != $mailbox->ticket_status) {
|
||
\Eventy::action('conversation.status_changed', $conversation, $user, true, $prev_status);
|
||
}
|
||
$conversation->last_reply_at = $now;
|
||
$conversation->last_reply_from = Conversation::PERSON_USER;
|
||
$conversation->user_updated_at = $now;
|
||
// Set folder id
|
||
$conversation->updateFolder();
|
||
$conversation->save();
|
||
|
||
// Update folders counters
|
||
$conversation->mailbox->updateFoldersCounters();
|
||
|
||
// Set CC for the thread to send user reply to CCed emails also.
|
||
if ($conv_cc) {
|
||
$cc = array_merge($cc, $conv_cc);
|
||
}
|
||
|
||
// Thread
|
||
$thread = new Thread();
|
||
$thread->conversation_id = $conversation->id;
|
||
$thread->user_id = $conversation->user_id;
|
||
$thread->type = Thread::TYPE_MESSAGE;
|
||
$thread->status = $conversation->status;
|
||
$thread->state = Thread::STATE_PUBLISHED;
|
||
$thread->message_id = $message_id;
|
||
$thread->headers = $this->headerToStr($headers);
|
||
$thread->body = $body;
|
||
$thread->from = $from;
|
||
// To must be customer's email
|
||
$thread->setTo([$conversation->customer_email]);
|
||
$thread->setCc($cc);
|
||
$thread->setBcc($bcc);
|
||
$thread->source_via = Thread::PERSON_USER;
|
||
$thread->source_type = Thread::SOURCE_TYPE_EMAIL;
|
||
$thread->customer_id = $conversation->customer_id;
|
||
$thread->created_by_user_id = $user_id;
|
||
$thread->created_at = $now;
|
||
$thread->updated_at = $now;
|
||
$thread->save();
|
||
|
||
$body_changed = false;
|
||
$saved_attachments = $this->saveAttachments($attachments, $thread->id);
|
||
if ($saved_attachments) {
|
||
// After attachments saved to the disk we can replace cids in body (for PLAIN and HTML body)
|
||
$thread->body = $this->replaceCidsWithAttachmentUrls($thread->body, $saved_attachments, $conversation, $prev_has_attachments);
|
||
$body_changed = true;
|
||
|
||
foreach ($saved_attachments as $saved_attachment) {
|
||
if (!$saved_attachment['attachment']->embedded) {
|
||
$thread->has_attachments = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
$new_body = Thread::replaceBase64ImagesWithAttachments($thread->body);
|
||
if ($new_body != $thread->body) {
|
||
$thread->body = $new_body;
|
||
$body_changed = true;
|
||
}
|
||
|
||
if ($body_changed) {
|
||
$thread->save();
|
||
}
|
||
|
||
event(new UserReplied($conversation, $thread));
|
||
\Eventy::action('conversation.user_replied', $conversation, $thread);
|
||
|
||
return $thread;
|
||
}
|
||
|
||
/**
|
||
* Save attachments from email.
|
||
*
|
||
* @param array $attachments
|
||
* @param int $thread_id
|
||
*
|
||
* @return bool
|
||
*/
|
||
public function saveAttachments($email_attachments, $thread_id)
|
||
{
|
||
$created_attachments = [];
|
||
foreach ($email_attachments as $email_attachment) {
|
||
$created_attachment = Attachment::create(
|
||
$this->processAttachmentName($email_attachment->getName()),
|
||
$email_attachment->getMimeType(),
|
||
Attachment::typeNameToInt($email_attachment->getType()),
|
||
$email_attachment->getContent(),
|
||
$uploaded_file = '',
|
||
$embedded = false,
|
||
$thread_id
|
||
);
|
||
if ($created_attachment) {
|
||
$created_attachments[] = [
|
||
'imap_attachment' => $email_attachment,
|
||
'attachment' => $created_attachment,
|
||
];
|
||
}
|
||
}
|
||
|
||
return $created_attachments;
|
||
}
|
||
|
||
public function processAttachmentName($name)
|
||
{
|
||
// Fix for Webklex/laravel-imap.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/2782
|
||
if (\Str::startsWith($name, '=?')) {
|
||
$name_decoded = \MailHelper::imapUtf8($name);
|
||
|
||
if ($name_decoded) {
|
||
return $name_decoded;
|
||
}
|
||
}
|
||
|
||
return $name;
|
||
}
|
||
|
||
/**
|
||
* Separate reply in the body.
|
||
*
|
||
* @param string $body
|
||
*
|
||
* @return string
|
||
*/
|
||
public function separateReply($body, $is_html, $is_reply, $user_reply_to_notification = false, $prev_message_id = '')
|
||
{
|
||
$cmp_reply_length_desc = function ($a, $b) {
|
||
if (mb_strlen($a) == mb_strlen($b)) {
|
||
return 0;
|
||
}
|
||
|
||
return (mb_strlen($a) < mb_strlen($b)) ? -1 : 1;
|
||
};
|
||
|
||
$result = '';
|
||
|
||
if ($is_html) {
|
||
// Extract body content from HTML
|
||
|
||
// Proton has it's own unique way of placing replies:
|
||
// https://github.com/freescout-help-desk/freescout/issues/4537#issuecomment-2629836738
|
||
if ($is_reply
|
||
&& ($protonmail_quote_pos = mb_strpos($body, '<div class="protonmail_quote">'))
|
||
&& ($html_pos = mb_strpos($body, '<html'))
|
||
&& $protonmail_quote_pos < $html_pos
|
||
) {
|
||
$body = mb_substr($body, 0, $protonmail_quote_pos);
|
||
}
|
||
|
||
// Split by <html>
|
||
$htmls = [];
|
||
preg_match_all("/<html[^>]*>(.*?)<\/html>/is", $body, $htmls);
|
||
|
||
if (empty($htmls[0])) {
|
||
$htmls[0] = [$body];
|
||
}
|
||
foreach ($htmls[0] as $html) {
|
||
// One body.
|
||
$dom = new \DOMDocument();
|
||
libxml_use_internal_errors(true);
|
||
//$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
|
||
//$dom->loadHTML(\Helper::mbConvertEncodingHtmlEntities($html));
|
||
$dom->loadHTML(\Symfony\Polyfill\Mbstring\Mbstring::mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') ?: $html);
|
||
libxml_use_internal_errors(false);
|
||
$bodies = $dom->getElementsByTagName('body');
|
||
if ($bodies->length == 1) {
|
||
$body_el = $bodies->item(0);
|
||
$html = $dom->saveHTML($body_el);
|
||
}
|
||
preg_match("/<body[^>]*>(.*?)<\/body>/is", $html, $matches);
|
||
if (count($matches)) {
|
||
$result .= $matches[1];
|
||
}
|
||
}
|
||
if (!$result) {
|
||
$result = $body;
|
||
}
|
||
} else {
|
||
$result = nl2br($body ?? '');
|
||
}
|
||
|
||
// This is reply, we need to separate reply text from old text
|
||
if ($is_reply) {
|
||
// Check all separators and choose the shortest reply
|
||
$reply_bodies = [];
|
||
|
||
$reply_separators = Mail::$alternative_reply_separators;
|
||
|
||
if (!empty($this->mailbox->before_reply)) {
|
||
$reply_separators[] = $this->mailbox->before_reply;
|
||
}
|
||
|
||
// If user replied to the email notification use only predefined reply separator.
|
||
// https://github.com/freescout-helpdesk/freescout/issues/3580
|
||
if ($user_reply_to_notification && strstr($result, \MailHelper::REPLY_SEPARATOR_NOTIFICATION)) {
|
||
$reply_separators = [\MailHelper::REPLY_SEPARATOR_NOTIFICATION];
|
||
}
|
||
|
||
// Try to separate customer reply using hashed reply separator.
|
||
// In this case some extra text may appear below customer's reply:
|
||
// On Thu, Jan 4, 2024 at 8:41 AM John Doe | Demo <test@example.org> wrote:
|
||
if (config('app.alternative_reply_separation')) {
|
||
if (!$user_reply_to_notification && $prev_message_id) {
|
||
$hashed_reply_separator = \MailHelper::getHashedReplySeparator($prev_message_id);
|
||
if (strstr($result, $hashed_reply_separator)) {
|
||
$reply_separators = [$hashed_reply_separator];
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach ($reply_separators as $reply_separator) {
|
||
if (\Str::startsWith($reply_separator, 'regex:')) {
|
||
$regex = preg_replace("/^regex:/", '', $reply_separator);
|
||
$parts = preg_split($regex, $result);
|
||
} else {
|
||
$parts = explode($reply_separator, $result);
|
||
}
|
||
if (count($parts) > 1) {
|
||
// Check if part contains any real text.
|
||
$text = \Helper::htmlToText($parts[0]);
|
||
$text = trim($text);
|
||
$text = preg_replace('/^\s+/mu', '', $text);
|
||
|
||
if ($text) {
|
||
$reply_bodies[] = $parts[0];
|
||
}
|
||
}
|
||
}
|
||
if (count($reply_bodies)) {
|
||
usort($reply_bodies, $cmp_reply_length_desc);
|
||
|
||
return $reply_bodies[0];
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
public function replaceCidsWithAttachmentUrls($body, $attachments, $conversation, $prev_has_attachments)
|
||
{
|
||
$only_embedded_attachments = true;
|
||
|
||
foreach ($attachments as $attachment) {
|
||
// webklex:
|
||
// [type] => image
|
||
// [content_type] => image/png
|
||
// [id] => ii_l0krlfiu0
|
||
// [name] => 2.png
|
||
// [disposition] => inline
|
||
// [img_src] => ...
|
||
//
|
||
// php-imap:
|
||
// [content] => ...
|
||
// [type] => text
|
||
// [part_number] => 3
|
||
// [content_type] => image/png
|
||
// [id] => ii_l0krolw00
|
||
// [name] => 2.png
|
||
// [disposition] => Webklex\PHPIMAP\Attribute Object
|
||
// (
|
||
// [name:protected] => content_disposition
|
||
// [values:protected] => Array
|
||
// (
|
||
// [0] => inline
|
||
// )
|
||
|
||
// )
|
||
// [img_src] =>
|
||
// [size] => 2326
|
||
if ($attachment['imap_attachment']->id && (isset($attachment['imap_attachment']->img_src) || strlen($attachment['imap_attachment']->content ?? ''))) {
|
||
$cid = 'cid:'.$attachment['imap_attachment']->id;
|
||
if (strstr($body, $cid)) {
|
||
$body = str_replace($cid, $attachment['attachment']->url(), $body);
|
||
// Set embedded flag for the attachment.
|
||
$attachment['attachment']->embedded = true;
|
||
$attachment['attachment']->save();
|
||
} else {
|
||
$only_embedded_attachments = false;
|
||
}
|
||
} else {
|
||
$only_embedded_attachments = false;
|
||
}
|
||
}
|
||
|
||
if ($only_embedded_attachments
|
||
&& $conversation
|
||
&& $conversation->has_attachments
|
||
&& !$prev_has_attachments
|
||
) {
|
||
$conversation->has_attachments = false;
|
||
$conversation->save();
|
||
}
|
||
|
||
return $body;
|
||
}
|
||
|
||
/**
|
||
* Convert email object to plain emails.
|
||
*
|
||
* @param array $obj_list
|
||
*
|
||
* @return array
|
||
*/
|
||
public function formatEmailList($obj_list)
|
||
{
|
||
$plain_list = [];
|
||
|
||
if (!$obj_list) {
|
||
return $plain_list;
|
||
}
|
||
|
||
$obj_list = $this->attrToArray($obj_list);
|
||
|
||
foreach ($obj_list as $item) {
|
||
$item->mail = Email::sanitizeEmail($item->mail);
|
||
if ($item->mail) {
|
||
$plain_list[] = $item->mail;
|
||
}
|
||
}
|
||
|
||
return $plain_list;
|
||
}
|
||
|
||
public function attrToArray($attr)
|
||
{
|
||
if (!$attr) {
|
||
return [];
|
||
}
|
||
|
||
if (is_object($attr) && get_class($attr) == 'Webklex\PHPIMAP\Attribute') {
|
||
$attr = $attr->get();
|
||
}
|
||
|
||
return $attr;
|
||
}
|
||
|
||
public function attrToDate($attr)
|
||
{
|
||
if (!$attr) {
|
||
return null;
|
||
}
|
||
|
||
if (is_object($attr) && get_class($attr) == 'Webklex\PHPIMAP\Attribute') {
|
||
$attr = $attr->toDate();
|
||
}
|
||
|
||
return $attr;
|
||
}
|
||
|
||
public function headerToStr($header)
|
||
{
|
||
if (!is_string($header)) {
|
||
$header = $header->raw;
|
||
}
|
||
return $header;
|
||
}
|
||
|
||
/**
|
||
* We have to sort messages manually, as they can be in non-chronological order.
|
||
*
|
||
* @param Collection $messages
|
||
*
|
||
* @return Collection
|
||
*/
|
||
public function sortMessage($messages)
|
||
{
|
||
$messages = $messages->sortBy(function ($message, $key) {
|
||
$date = $message->getDate();
|
||
if ($date) {
|
||
if (isset($message->getDate()->timestamp)) {
|
||
return $message->getDate()->timestamp;
|
||
} else {
|
||
return (string)$message->getDate();
|
||
}
|
||
} else {
|
||
return 0;
|
||
}
|
||
});
|
||
|
||
return $messages;
|
||
}
|
||
|
||
/**
|
||
* Create customers from emails.
|
||
*
|
||
* @param array $emails_data
|
||
*/
|
||
public function createCustomers($emails, $exclude_emails)
|
||
{
|
||
foreach ($emails as $item) {
|
||
// Email belongs to mailbox
|
||
// if (in_array(Email::sanitizeEmail($item->mail), $exclude_emails)) {
|
||
// continue;
|
||
// }
|
||
$data = [];
|
||
if (!empty($item->personal)) {
|
||
$name_parts = explode(' ', $item->personal, 2);
|
||
$data['first_name'] = $name_parts[0];
|
||
if (!empty($name_parts[1])) {
|
||
$data['last_name'] = $name_parts[1];
|
||
}
|
||
}
|
||
Customer::create($item->mail, $data);
|
||
}
|
||
}
|
||
|
||
public function setSeen($message, $mailbox)
|
||
{
|
||
$flag = \Eventy::filter('fetch_emails.set_seen_flag', ['Seen'], $message, $mailbox);
|
||
$message->setFlag($flag);
|
||
\Eventy::action('fetch_emails.after_set_seen', $message, $mailbox, $this);
|
||
}
|
||
}
|