diff --git a/README.md b/README.md index ac08cad..fc68d2b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ composer require "millions-missing-france/freescout-restricted-customers" "0.5.0 ### Edit the application routes This package does not seem to correctly override the routes of the main application. -Overriding them has to be done manually, in the two following files. +Overriding them has to be done manually, in the three following files. #### routes/web.php @@ -137,6 +137,24 @@ should be replaced with: $html = __('Customers').' \' class="btn btn-bordered btn-xs" style="position:relative;top:-1px;margin-left:4px;">'; ``` +### Edit the artisan commands + +#### app/Console/Kernel.php + +At the lines 107-108, this: + +```php + $fetch_command_identifier = \Helper::getWorkerIdentifier('freescout:fetch-emails'); + $fetch_command_name = 'freescout:fetch-emails' +``` + +should be replaced with: + +```php + $fetch_command_identifier = \Helper::getWorkerIdentifier('freescout-restricted-customers:fetch-emails'); + $fetch_command_name = 'freescout-restricted-customers:fetch-emails' +``` + ### Update the database schema ``` diff --git a/src/Console/Commands/FetchEmails.php b/src/Console/Commands/FetchEmails.php new file mode 100644 index 0000000..71e8629 --- /dev/null +++ b/src/Console/Commands/FetchEmails.php @@ -0,0 +1,771 @@ + + */ + +namespace MillionsMissingFrance\FreescoutRestrictedCustomers\Console\Commands; + +use App\Conversation; +use MillionsMissingFrance\FreescoutRestrictedCustomers\Customer; +use App\Events\CustomerCreatedConversation; +use App\Events\CustomerReplied; +use App\Thread; +use app\Console\Commands\FetchEmails as BaseFetchEmails; + +class FetchEmails extends BaseFetchEmails { + /** + * 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-restricted-customers:fetch-emails {--days=3} {--unseen=1} {--identifier=dummy} {--mailboxes=0}'; + + /** + * 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, + // and linked to a specific Mailbox. + $customer = Customer::create($from, [ 'mailbox_id' => $mailbox->id ]); + 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; + } + + 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 = $message->getInReplyTo(); + $references = $message->getReferences(); + $attachments = $message->getAttachments(); + $html_body = ''; + + // Is it a bounce message + $is_bounce = false; + + // Determine previous Message-ID + $prev_message_id = ''; + if ($in_reply_to) { + $prev_message_id = trim($in_reply_to, '<>'); + } elseif ($references) { + if (!is_array($references)) { + $references = array_filter(preg_split('/[, <>]/', $references)); + } + // Find first non-empty reference + if (is_array($references)) { + foreach ($references as $reference) { + if (!empty(trim($reference))) { + $prev_message_id = trim($reference); + break; + } + } + } + } + + // 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. + if (!$prev_message_id || !preg_match('/^('.implode('|', $reply_prefixes).')\-(\d+)\-/', $prev_message_id)) { + $html_body = $message->getHTMLBody(false); + $marker_message_id = \MailHelper::fetchMessageMarkerValue($html_body); + + if ($marker_message_id) { + $prev_message_id = $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; + } + } + } + } + + // 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; + } + $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 (!empty($prev_thread)) { + $is_reply = true; + } + } + } + + // 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); + } + $body = $this->separateReply($body, $is_html, $is_reply, !$message_from_customer, (($message_from_customer && $prev_thread) ? $prev_thread->getMessageId($mailbox) : '')); + + // 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 ($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 + && preg_match("/^[\s]*".self::FWD_AS_CUSTOMER_COMMAND."/su", trim(strip_tags($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)); + } + } + } + + // 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()) + ); + // We need the createCustomers method to be aware of the Mailbox currently in use. + $this->createCustomers($emails, $mailbox); + + $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)); + } + } + + /** + * Create customers from emails. + * + * @param array $emails_data + */ + public function createCustomers($emails, $mailbox) { + $exclude_emails = $mailbox->getEmails(); + foreach ($emails as $item) { + // Email belongs to mailbox + // if (in_array(Email::sanitizeEmail($item->mail), $exclude_emails)) { + // continue; + // } + $data = [ + 'mailbox_id' => $mailbox->id, + ]; + 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); + } + } +} diff --git a/src/Customer.php b/src/Customer.php index 1216db5..e76798f 100644 --- a/src/Customer.php +++ b/src/Customer.php @@ -49,6 +49,85 @@ class Customer extends BaseCustomer { return $this->belongsTo(Mailbox::class); } + /** + * Create customer or get existing and fill empty fields. + * + * @param string $email + * @param array $data [description] + * + * @return [type] [description] + */ + public static function create($email, $data = []) { + $new = false; + + $email = Email::sanitizeEmail($email); + if (!$email) { + return null; + } + $email_obj = Email::where('email', $email)->first(); + if ($email_obj) { + $customer = $email_obj->customer; + + // In case somehow the email has no customer. + if (!$customer) { + // Customer will be saved and connected to the email later. + $customer = new self(); + } + + // Update name if empty. + /*if (empty($customer->first_name) && !empty($data['first_name'])) { + $customer->first_name = $data['first_name']; + if (empty($customer->last_name) && !empty($data['last_name'])) { + $customer->last_name = $data['last_name']; + } + $customer->save(); + }*/ + } else { + $customer = new self(); + $email_obj = new Email(); + $email_obj->email = $email; + + $new = true; + } + // Ensure that the Mailbox relationship is set, if provided. + if ( in_array('mailbox_id', $data) ) + $customer->mailbox_id = $data['mailbox_id']; + + // Set empty fields + if ($customer->setData($data, false) || !$customer->id) { + $customer->save(); + } + + if (empty($email_obj->id) || !$email_obj->customer_id || $email_obj->customer_id != $customer->id) { + // Email may have been set in setData(). + $save_email = true; + if (!empty($data['emails']) && is_array($data['emails'])) { + foreach ($data['emails'] as $data_email) { + if (is_string($data_email) && $data_email == $email) { + $save_email = false; + break; + } + if (is_array($data_email) && !empty($data_email['value']) && $data_email['value'] == $email) { + $save_email = false; + break; + } + } + } + if ($save_email) { + $email_obj->customer()->associate($customer); + $email_obj->save(); + } + } + + // Todo: check phone uniqueness. + + if ($new) { + \Eventy::action('customer.created', $customer); + } + + return $customer; + } + /** * Set empty fields. */ diff --git a/src/FreescoutRestrictedCustomersServiceProvider.php b/src/FreescoutRestrictedCustomersServiceProvider.php index 1b56933..eb5577c 100644 --- a/src/FreescoutRestrictedCustomersServiceProvider.php +++ b/src/FreescoutRestrictedCustomersServiceProvider.php @@ -8,6 +8,7 @@ namespace MillionsMissingFrance\FreescoutRestrictedCustomers; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use MillionsMissingFrance\FreescoutRestrictedCustomers\Console\Commands\FetchEmails; class FreescoutRestrictedCustomersServiceProvider extends ServiceProvider { public function register() { @@ -18,6 +19,9 @@ class FreescoutRestrictedCustomersServiceProvider extends ServiceProvider { $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); $this->registerRoutes(); $this->loadViewsFrom(__DIR__.'/../resources/views', 'freescout-restricted-customers'); + $this->commands([ + FetchEmails::class, + ]); } protected function registerRoutes() {