1
0
mirror of https://github.com/freescout-helpdesk/freescout.git synced 2024-11-24 03:12:46 +01:00

Fetch email attachments

This commit is contained in:
FreeScout 2018-08-04 03:50:55 -07:00
parent d93925c870
commit ea7e6be8ee
11 changed files with 341 additions and 16 deletions

148
app/Attachment.php Normal file
View File

@ -0,0 +1,148 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Attachment extends Model
{
const TYPE_TEXT = 0;
const TYPE_MULTIPART = 1;
const TYPE_MESSAGE = 2;
const TYPE_APPLICATION = 3;
const TYPE_AUDIO = 4;
const TYPE_IMAGE = 5;
const TYPE_VIDEO = 6;
const TYPE_MODEL = 7;
const TYPE_OTHER = 8;
// https://github.com/Webklex/laravel-imap/blob/master/src/IMAP/Attachment.php
public static $types = [
'message' => self::TYPE_MESSAGE,
'application' => self::TYPE_APPLICATION,
'audio' => self::TYPE_AUDIO,
'image' => self::TYPE_IMAGE,
'video' => self::TYPE_VIDEO,
'model' => self::TYPE_MODEL,
'text' => self::TYPE_TEXT,
'multipart' => self::TYPE_MULTIPART,
'other' => self::TYPE_OTHER,
];
const DIRECTORY = 'attachment';
public $timestamps = false;
/**
* Get thread.
*/
public function thread()
{
return $this->belongsTo('App\Thread');
}
/**
* Save attachment to file and database.
*/
public static function create($name, $mime_type, $type, $content, $thread_id = null)
{
if (!$content) {
return false;
}
$file_name = $name;
$attachment = new Attachment();
$attachment->thread_id = $thread_id;
$attachment->name = $name;
$attachment->file_name = $file_name;
$attachment->mime_type = $mime_type;
$attachment->type = $type;
//$attachment->size = Storage::size($file_path);
$attachment->save();
$file_path = self::DIRECTORY.DIRECTORY_SEPARATOR.self::getPath($attachment->id).$file_name;
Storage::put($file_path, $content);
$attachment->size = Storage::size($file_path);
if ($attachment->size) {
$attachment->save();
}
return true;
}
/**
* Get file path by ID.
*
* @param integer $id
* @return string
*/
public static function getPath($id)
{
$hash = md5($id);
$first = -1;
$second = 0;
for ($i = 0; $i < strlen($hash); $i++) {
if (is_numeric($hash[$i])) {
if ($first == -1) {
$first = $hash[$i];
} else {
$second = $hash[$i];
break;
}
}
}
if ($first == -1) {
$first = 0;
}
return $first.DIRECTORY_SEPARATOR.$second.DIRECTORY_SEPARATOR;
}
/**
* Conver type name to integer.
*/
public static function typeNameToInt($type_name)
{
if (!empty(self::$types[$type_name])) {
return self::$types[$type_name];
} else {
return self::TYPE_OTHER;
}
}
/**
* Get attachment public URL.
*
* @return string
*/
public function getUrl()
{
return Storage::url(self::DIRECTORY.DIRECTORY_SEPARATOR.self::getPath($this->id).$this->file_name);
}
/**
* Convert size into human readable format.
*
* @return string
*/
public function getSizeName()
{
return self::formatBytes($this->size);
}
public static function formatBytes($size, $precision = 0)
{
$size = (int) $size;
if ($size > 0) {
$base = log($size) / log(1024);
$suffixes = array(' b', ' KB', ' MB', ' GB', ' TB');
return round(pow(1024, $base - floor($base)), $precision) . $suffixes[floor($base)];
} else {
return $size;
}
}
}

View File

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Attachment;
use App\Conversation;
use App\Customer;
use App\Email;
@ -28,6 +29,13 @@ class FetchEmails extends Command
*/
protected $description = 'Fetch emails from mailboxes addresses';
/**
* Current mailbox.
*
* @var Mailbox
*/
public $mailbox;
/**
* Create a new command instance.
*
@ -56,17 +64,12 @@ class FetchEmails extends Command
foreach ($mailboxes as $mailbox) {
$this->info('['.date('Y-m-d H:i:s').'] Mailbox: '.$mailbox->name);
$this->mailbox = $mailbox;
try {
$this->fetch($mailbox);
} catch (\Exception $e) {
$this->error('['.date('Y-m-d H:i:s').'] Error: '.$e->getMessage().'; Line: '.$e->getLine());
activity()
->withProperties([
'error' => $e->getMessage(),
'mailbox' => $mailbox->name,
])
->useLog(\App\ActivityLog::NAME_EMAILS_FETCHING)
->log(\App\ActivityLog::DESCRIPTION_EMAILS_FETCHING_ERROR);
$this->logError('Error: '.$e->getMessage().'; Line: '.$e->getLine());
}
}
}
@ -96,6 +99,10 @@ class FetchEmails extends Command
// Get unseen messages for a period
$messages = $folder->query()->unseen()->since(now()->subDays(1))->leaveUnread()->get();
if ($client->getLastError()) {
$this->logError($client->getLastError());
}
$this->line('['.date('Y-m-d H:i:s').'] Fetched: '.count($messages));
$message_index = 1;
@ -119,7 +126,7 @@ class FetchEmails extends Command
$body = $this->separateReply($body, false);
}
if (!$body) {
$this->error('['.date('Y-m-d H:i:s').'] Message body is empty');
$this->logError('Message body is empty');
$message->setFlag(['Seen']);
continue;
}
@ -130,7 +137,7 @@ class FetchEmails extends Command
$from = $message->getFrom();
}
if (!$from) {
$this->error('['.date('Y-m-d H:i:s').'] From is empty');
$this->logError('From is empty');
$message->setFlag(['Seen']);
continue;
} else {
@ -158,11 +165,29 @@ class FetchEmails extends Command
$message->setFlag(['Seen']);
$this->line('['.date('Y-m-d H:i:s').'] Processed');
} else {
$this->error('['.date('Y-m-d H:i:s').'] Error occured processing message');
$this->logError('Error occured processing message');
}
}
}
public function logError($message)
{
$this->error('['.date('Y-m-d H:i:s').'] '.$message);
$mailbox_name = '';
if ($this->mailbox) {
$mailbox_name = $this->mailbox->name;
}
activity()
->withProperties([
'error' => $message,
'mailbox' => $mailbox_name,
])
->useLog(\App\ActivityLog::NAME_EMAILS_FETCHING)
->log(\App\ActivityLog::DESCRIPTION_EMAILS_FETCHING_ERROR);
}
/**
* Save email as thread.
*/
@ -195,7 +220,6 @@ class FetchEmails extends Command
$conversation = new Conversation();
$conversation->type = Conversation::TYPE_EMAIL;
$conversation->status = Conversation::STATUS_ACTIVE;
$conversation->state = Conversation::STATE_PUBLISHED;
$conversation->subject = $subject;
$conversation->setCc($cc);
@ -210,6 +234,8 @@ class FetchEmails extends Command
$conversation->source_via = Conversation::PERSON_CUSTOMER;
$conversation->source_type = Conversation::SOURCE_TYPE_EMAIL;
}
// Reply from customer makes conversation active
$conversation->status = Conversation::STATUS_ACTIVE;
$conversation->last_reply_at = $now;
$conversation->last_reply_from = Conversation::PERSON_USER;
// Set folder id
@ -233,11 +259,42 @@ class FetchEmails extends Command
$thread->created_by_customer_id = $customer->id;
$thread->save();
$has_attachments = $this->saveAttachments($attachments, $thread->id);
if ($has_attachments) {
$thread->has_attachments = true;
$thread->save();
}
event(new CustomerReplied($conversation, $thread, $new));
return true;
}
/**
* Save attachments from email.
*
* @param array $attachments
* @param integer $thread_id
* @return bool
*/
public function saveAttachments($email_attachments, $thread_id)
{
$has_attachments = false;
foreach ($email_attachments as $email_attachment) {
$create_result = Attachment::create(
$email_attachment->getName(),
$email_attachment->getMimeType(),
Attachment::typeNameToInt($email_attachment->getType()),
$email_attachment->getContent(),
$thread_id
);
if ($create_result) {
$has_attachments = true;
}
}
return $has_attachments;
}
/**
* Separate reply in the body.
*

View File

@ -220,6 +220,18 @@ class Conversation extends Model
}
$text = strip_tags($text);
$text = trim(preg_replace('/\s+/', ' ', $text));
// Remove "undetectable" whitespaces
$whitespaces = ["%81", "%7F", "%C5%8D", "%8D", "%8F", "%C2%90", "%C2", "%90", "%9D", "%C2%A0", "%A0", "%C2%AD", "%AD", "%08", "%09", "%0A", "%0D"];
$text = urlencode($text);
foreach($whitespaces as $char){
$text = str_replace($char, ' ', $text);
}
$text = urldecode($text);
$text = trim(preg_replace('/[ ]+/', ' ', $text));
$this->preview = mb_substr($text, 0, self::PREVIEW_MAXLENGTH);
return $this->preview;

View File

@ -171,6 +171,14 @@ class Thread extends Model
return $this->belongsTo('App\Conversation');
}
/**
* Get thread attachmets.
*/
public function attachments()
{
return $this->hasMany('App\Attachment');
}
/**
* Get user who created the thread.
*/

View File

@ -44,8 +44,10 @@ return [
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
'public' => [

View File

@ -33,6 +33,7 @@ class CreateThreadsTable extends Migration
$table->text('to')->nullable(); // JSON
$table->text('cc')->nullable(); // JSON
$table->text('bcc')->nullable(); // JSON
$table->boolean('has_attachments')->default(false);
// Email Message-ID header for email received from customer
$table->string('message_id', 998)->nullable();
// source.via - Originating source of the thread - user or customer

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateAttachmentsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('attachments', function (Blueprint $table) {
$table->increments('id');
$table->integer('thread_id')->nullable();
$table->string('name', 255)->nullable();
$table->string('file_name', 255);
$table->string('mime_type', 127);
$table->unsignedInteger('type');
$table->unsignedInteger('size')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('attachments');
}
}

46
public/css/style.css vendored
View File

@ -1328,6 +1328,35 @@ table.table-conversations td.conv-attachment {
.thread-lineitem .thread-header {
min-height: auto;
}
.thread-attachments {
position: relative;
}
.thread-attachments ul {
list-style: none;
padding-left: 23px;
}
.thread-attachments li {
border-radius: 4px;
border: 1px solid #dadada;
display: inline-block;
line-height: 1;
background-color: #f5f5f5;
margin: 8px 4px 8px 0;
padding: 4px 10px;
position: relative;
}
.thread-attachments i {
color: #a5b2bd;
font-size: 14px;
position: absolute;
top: 12px;
}
.thread-attachments a {
display: inline-block;
}
.thread-attachments span {
text-transform: lowercase;
}
.conv-new #conv-toolbar {
border-bottom: 1px solid #d4d9dd;
padding: 6px 0 7px 0;
@ -1934,4 +1963,21 @@ a.text-danger,
a.text-danger:hover,
a.text-danger:focus {
color: #cc1f19!important;
}
.break-words {
/* These are technically the same, but use both */
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
/* This is the dangerous one in WebKit, as it breaks things wherever */
word-break: break-all;
/* Instead use this non-standard one: */
word-break: break-word;
/* Adds a hyphen where the word breaks, if supported (No Blink) */
-ms-hyphens: auto;
-moz-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}

View File

@ -262,6 +262,19 @@
<div class="thread-body">
{!! $thread->getCleanBody() !!}
</div>
@if ($thread->has_attachments)
<div class="thread-attachments">
<i class="glyphicon glyphicon-paperclip"></i>
<ul>
@foreach ($thread->attachments as $attachment)
<li>
<a href="{{ $attachment->getUrl() }}" class="break-words" target="_blank">{{ $attachment->name }}</a>
<span class="text-help">{{ $attachment->getSizeName() }}</span>
</li>
@endforeach
</ul>
</div>
@endif
</div>
<div class="dropdown thread-options">
<span class="dropdown-toggle glyphicon glyphicon-option-vertical" data-toggle="dropdown"></span>

View File

@ -15,9 +15,10 @@ return array(
'App\\Conversation' => $baseDir . '/app/Conversation.php',
'App\\Customer' => $baseDir . '/app/Customer.php',
'App\\Email' => $baseDir . '/app/Email.php',
'App\\Events\\ConversationCreated' => $baseDir . '/app/Events/ConversationCreated.php',
'App\\Events\\ConversationStatusChanged' => $baseDir . '/app/Events/ConversationStatusChanged.php',
'App\\Events\\ConversationUserChanged' => $baseDir . '/app/Events/ConversationUserChanged.php',
'App\\Events\\CustomerReplied' => $baseDir . '/app/Events/CustomerReplied.php',
'App\\Events\\UserReplied' => $baseDir . '/app/Events/UserReplied.php',
'App\\Exceptions\\Handler' => $baseDir . '/app/Exceptions/Handler.php',
'App\\FailedJob' => $baseDir . '/app/FailedJob.php',
'App\\Folder' => $baseDir . '/app/Folder.php',

View File

@ -406,9 +406,10 @@ class ComposerStaticInit992853026b5d9a5aba1df559a69505cb
'App\\Conversation' => __DIR__ . '/../..' . '/app/Conversation.php',
'App\\Customer' => __DIR__ . '/../..' . '/app/Customer.php',
'App\\Email' => __DIR__ . '/../..' . '/app/Email.php',
'App\\Events\\ConversationCreated' => __DIR__ . '/../..' . '/app/Events/ConversationCreated.php',
'App\\Events\\ConversationStatusChanged' => __DIR__ . '/../..' . '/app/Events/ConversationStatusChanged.php',
'App\\Events\\ConversationUserChanged' => __DIR__ . '/../..' . '/app/Events/ConversationUserChanged.php',
'App\\Events\\CustomerReplied' => __DIR__ . '/../..' . '/app/Events/CustomerReplied.php',
'App\\Events\\UserReplied' => __DIR__ . '/../..' . '/app/Events/UserReplied.php',
'App\\Exceptions\\Handler' => __DIR__ . '/../..' . '/app/Exceptions/Handler.php',
'App\\FailedJob' => __DIR__ . '/../..' . '/app/FailedJob.php',
'App\\Folder' => __DIR__ . '/../..' . '/app/Folder.php',