diff --git a/freescout-dist/.editorconfig b/freescout-dist/.editorconfig
new file mode 100644
index 0000000..6f2164b
--- /dev/null
+++ b/freescout-dist/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.json]
+insert_final_newline = false
diff --git a/freescout-dist/.env.example b/freescout-dist/.env.example
new file mode 100644
index 0000000..883337e
--- /dev/null
+++ b/freescout-dist/.env.example
@@ -0,0 +1,47 @@
+####################################################################################################
+## If you want to use web installer **DO NOT** create `.env` file manually.
+## If `.env` file exists in the root of your app, web installer won't run.
+##
+## Every time you are making changes in .env file, in order changes to take an effect you need to run:
+## php artisan freescout:clear-cache
+#####################################################################################################
+
+# Application URL
+APP_URL=https://example.com
+
+# If you are using HTTPS, feel free to uncomment this line to improve security
+#SESSION_SECURE_COOKIE=true
+
+# Enter your proxy address here if freescout.net is not available from your server
+# (access to freescout.net is required to obtain official modules)
+#APP_PROXY=
+
+# Custom headers to add to all outgoing emails.
+#APP_CUSTOM_MAIL_HEADERS="IsTransactional:True;X-Custom-Header:value"
+
+# Uncomment if you have many folders and you are experiencing performance issues
+#APP_UPDATE_FOLDER_COUNTERS_IN_BACKGROUND=true
+
+# Timezones: https://github.com/freescout-helpdesk/freescout/wiki/PHP-Timezones
+# Comment it to use default timezone from php.ini
+#APP_TIMEZONE=Europe/London
+
+# Comma separated list of trusted proxies for proper IP detection in FreeScout.
+# To trust all proxies that connect to your server use single asterisk: *
+# To trust ALL proxies, including those that are in a chain of forwarding use double asterisk: **
+#APP_TRUSTED_PROXIES=192.168.1.1,192.168.1.2,192.168.1.3
+
+DB_CONNECTION=mysql
+DB_HOST=localhost
+DB_PORT=3306
+DB_DATABASE=
+DB_USERNAME=
+# Maximum password length is 50 characters
+DB_PASSWORD=
+
+# Run the following console command to generate the key: php artisan key:generate
+# Otherwise application will show the following error: "Whoops, looks like something went wrong"
+APP_KEY=
+
+# Uncomment to see errors in your browser, don't forget to comment it back when debugging finished
+#APP_DEBUG=true
diff --git a/freescout-dist/.env.travis b/freescout-dist/.env.travis
new file mode 100644
index 0000000..3045790
--- /dev/null
+++ b/freescout-dist/.env.travis
@@ -0,0 +1,10 @@
+APP_ENV=testing
+APP_KEY=SomeRandomString7
+
+DB_CONNECTION=testing
+DB_TEST_USERNAME=root
+DB_TEST_PASSWORD=
+
+CACHE_DRIVER=array
+SESSION_DRIVER=array
+QUEUE_DRIVER=sync
\ No newline at end of file
diff --git a/freescout-dist/.gitattributes b/freescout-dist/.gitattributes
new file mode 100644
index 0000000..967315d
--- /dev/null
+++ b/freescout-dist/.gitattributes
@@ -0,0 +1,5 @@
+* text=auto
+*.css linguist-vendored
+*.scss linguist-vendored
+*.js linguist-vendored
+CHANGELOG.md export-ignore
diff --git a/freescout-dist/.gitcommit b/freescout-dist/.gitcommit
new file mode 100644
index 0000000..b62fed1
--- /dev/null
+++ b/freescout-dist/.gitcommit
@@ -0,0 +1 @@
+28e2d659db742540723b7d6cea7f0261cfe34bf1
diff --git a/freescout-dist/.github/ISSUE_TEMPLATE/general_help_request.md b/freescout-dist/.github/ISSUE_TEMPLATE/general_help_request.md
new file mode 100644
index 0000000..2db1823
--- /dev/null
+++ b/freescout-dist/.github/ISSUE_TEMPLATE/general_help_request.md
@@ -0,0 +1,23 @@
+---
+name: General Help Request
+about: Create a general help request
+
+---
+
+
+PHP version:
+FreeScout version:
+Database: MySQL / PostgreSQL
+Are you using CloudFlare: Yes / No
\ No newline at end of file
diff --git a/freescout-dist/.github/PULL_REQUEST_TEMPLATE.md b/freescout-dist/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..49a1813
--- /dev/null
+++ b/freescout-dist/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,3 @@
+Keep in mind that pull requests should be sent to the `master` branch! See https://github.com/freescout-helpdesk/freescout/wiki/Development-Guide#github-workflow
+
+Now you can delete this text and type the description of your pull request...
\ No newline at end of file
diff --git a/freescout-dist/.github/workflows/lint-php.yml b/freescout-dist/.github/workflows/lint-php.yml
new file mode 100644
index 0000000..de2c81f
--- /dev/null
+++ b/freescout-dist/.github/workflows/lint-php.yml
@@ -0,0 +1,20 @@
+name: PHP Code Sniffer
+
+on:
+ workflow_dispatch:
+
+jobs:
+ build:
+ name: Lint PHP
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.1
+ tools: phpcs
+
+ - name: Run check
+ run: phpcs
diff --git a/freescout-dist/.github/workflows/test-pgsql.yml b/freescout-dist/.github/workflows/test-pgsql.yml
new file mode 100644
index 0000000..4cf6462
--- /dev/null
+++ b/freescout-dist/.github/workflows/test-pgsql.yml
@@ -0,0 +1,59 @@
+name: Test App (PostgreSQL)
+
+on:
+ push:
+ branches:
+ - master
+ workflow_dispatch:
+
+jobs:
+ test:
+ name: Test App (PostgreSQL)
+ runs-on: ubuntu-latest
+
+ env:
+ DB_CONNECTION: testing_pgsql
+
+ services:
+ postgres:
+ image: postgres:latest
+ env:
+ POSTGRES_USER: freescout-test
+ POSTGRES_PASSWORD: freescout-test
+ POSTGRES_DB: freescout-test
+ ports:
+ - 5432:5432
+ # Set health checks to wait until postgres has started
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ strategy:
+ matrix:
+ php: ['7.3', '7.4', '8.0', '8.1', '8.2']
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: pgsql, mbstring, xml, imap, zip, gd, curl, intl, json
+
+ - name: Install composer dependencies
+ run: composer install --ignore-platform-reqs --no-interaction
+
+ - name: Migrate and seed the database
+ run: |
+ php${{ matrix.php }} artisan migrate --force -n --database=testing_pgsql
+ php${{ matrix.php }} artisan db:seed --force -n --database=testing_pgsql
+ env:
+ DB_PORT: ${{ job.services.postgres.ports[5432] }}
+
+ - name: Run PHP tests
+ run: php${{ matrix.php }} ./vendor/bin/phpunit
+ env:
+ DB_PORT: ${{ job.services.postgres.ports[5432] }}
\ No newline at end of file
diff --git a/freescout-dist/.github/workflows/test.yml b/freescout-dist/.github/workflows/test.yml
new file mode 100644
index 0000000..6e327e7
--- /dev/null
+++ b/freescout-dist/.github/workflows/test.yml
@@ -0,0 +1,45 @@
+name: Test App (MySQL)
+
+on:
+ push:
+ branches:
+ - master
+ workflow_dispatch:
+
+jobs:
+ test:
+ name: Test App (MySQL)
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php: ['7.3', '7.4', '8.0', '8.1', '8.2']
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mysql, mbstring, xml, imap, zip, gd, curl, intl, json
+
+ - name: Start MySQL
+ run: |
+ sudo systemctl start mysql
+
+ - name: Setup database
+ run: |
+ mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `freescout-test`;'
+ mysql -uroot -proot -e "CREATE USER 'freescout-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'freescout-test';"
+ mysql -uroot -proot -e "GRANT ALL ON \`freescout-test\`.* TO 'freescout-test'@'localhost';"
+ mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
+
+ - name: Install composer dependencies
+ run: composer install --ignore-platform-reqs --no-interaction
+
+ - name: Migrate and seed the database
+ run: |
+ php${{ matrix.php }} artisan migrate --force -n --database=testing
+ php${{ matrix.php }} artisan db:seed --force -n --database=testing
+
+ - name: Run PHP tests
+ run: php${{ matrix.php }} ./vendor/bin/phpunit
\ No newline at end of file
diff --git a/freescout-dist/.gitignore b/freescout-dist/.gitignore
new file mode 100644
index 0000000..29e5382
--- /dev/null
+++ b/freescout-dist/.gitignore
@@ -0,0 +1,36 @@
+/node_modules
+/public/hot
+/public/storage
+/storage/*.key
+# We are committing /vendor directory to make installation process super easy, even on a shared hosting:
+# - https://www.codeenigma.com/build/blog/do-you-really-need-composer-production
+# - https://getcomposer.org/doc/faqs/should-i-commit-the-dependencies-in-my-vendor-directory.md
+/vendor/**/.git
+#/vendor
+/.idea
+/.vagrant
+Homestead.json
+Homestead.yaml
+npm-debug.log
+yarn-error.log
+.env
+app/Console/Commands/Test*
+/bootstrap/compiled.php
+composer.phar
+#composer.lock
+.DS_Store
+Thumbs.db
+#/.htaccess
+/public/css/builds/
+/public/js/builds/
+/public/.well-known
+/Modules
+/Modules/**/.git
+/public/modules
+/public/docs
+/storage/.ignore_locales
+/storage/.installed
+/tools
+.well-known
+/resources/lang/module.*
+.phpunit.result.cache
\ No newline at end of file
diff --git a/freescout-dist/.htaccess b/freescout-dist/.htaccess
new file mode 100644
index 0000000..d92a72c
--- /dev/null
+++ b/freescout-dist/.htaccess
@@ -0,0 +1,10 @@
+# On some hostings it is impossible to change web root directory
+# so we rewrite all web requests into /public folder
+
+
+[![PHP version](https://freescout-helpdesk.github.io/img/badges/PHP-7.1%2B-blue.svg)](https://github.com/freescout-helpdesk/freescout#requirements) [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Ffreescout-helpdesk%2Ffreescout&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com)
+
+php artisan schedule:run
';
+
+ if (\Option::get('alert_fetch') && !\Option::get('alert_fetch_sent')) {
+ // We send alert only once
+ \Option::set('alert_fetch_sent', true);
+ \MailHelper::sendAlertMail($text, 'Fetching Problems');
+ }
+
+ $this->error('['.date('Y-m-d H:i:s').'] '.$text);
+ } elseif (!$last_successful_run) {
+ $this->line('['.date('Y-m-d H:i:s').'] Fetching has not been configured yet');
+ } else {
+ if (\Option::get('alert_fetch_sent')) {
+ $text = 'Previously there were some problems fetching emails. Fetching recovered and functioning now!';
+
+ \MailHelper::sendAlertMail($text, 'Fetching Recovered');
+ }
+ \Option::set('alert_fetch_sent', false);
+
+ $this->info('['.date('Y-m-d H:i:s').'] Fetching is working');
+ }
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/GenerateVars.php b/freescout-dist/app/Console/Commands/GenerateVars.php
new file mode 100644
index 0000000..e62b226
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/GenerateVars.php
@@ -0,0 +1,77 @@
+ \Helper::getAllLocales(),
+ ];
+
+ //$filesystem = new Filesystem();
+
+ //$file_path = public_path('js/vars.js');
+ $file_path = storage_path('app/public/js/vars.js');
+
+ $content = view('js/vars', $params)->render();
+
+ //$filesystem->put($file_path, $content);
+ // Save vars only if content changed
+ try {
+ if (\Storage::exists('js/vars.js')) {
+ $old_content = \Storage::get('js/vars.js');
+ if ($content != $old_content) {
+ \Storage::put('js/vars.js', $content);
+ }
+ } else {
+ \Storage::put('js/vars.js', $content);
+ }
+ $this->info("Created: ".substr($file_path, strlen(base_path())+1));
+ } catch (\Exception $e) {
+ $msg = "Error occurred saving /storage/app/public/js/vars.js. ".\Helper::formatException($e);
+ \Log::error($msg);
+ $this->error($msg);
+ }
+ } catch (\Exception $e) {
+ $this->error($e->getMessage());
+ }
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/LogoutUsers.php b/freescout-dist/app/Console/Commands/LogoutUsers.php
new file mode 100644
index 0000000..01cb77c
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/LogoutUsers.php
@@ -0,0 +1,61 @@
+getPathname());
+ if ($deleted) {
+ $count++;
+ }
+ } catch (\Exception $e) {
+ $this->error($e->getMessage());
+ }
+ }
+ $this->line('Deleted sessions: '.$count);
+ } catch (\Exception $e) {
+ $this->error($e->getMessage());
+ }
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/LogsMonitor.php b/freescout-dist/app/Console/Commands/LogsMonitor.php
new file mode 100644
index 0000000..6750428
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/LogsMonitor.php
@@ -0,0 +1,96 @@
+ config('app.alert_logs_period'),
+ ]);
+
+ if (!$options['alert_logs_names']) {
+ $this->error('['.date('Y-m-d H:i:s').'] No logs to monitor selected');
+
+ return;
+ }
+ if (!$options['alert_logs_period']) {
+ $this->error('['.date('Y-m-d H:i:s').'] No logs monitoring period set');
+
+ return;
+ }
+
+ $logs = \App\ActivityLog::whereIn('log_name', $options['alert_logs_names'])
+ ->where('created_at', '>=', \Carbon\Carbon::now()->modify('-1 '.$options['alert_logs_period'])->toDateTimeString())
+ ->where('created_at', '<', $now->toDateTimeString())
+ ->get();
+
+ if (!count($logs)) {
+ $this->line('['.date('Y-m-d H:i:s').'] No new log records found for the last '.$options['alert_logs_period']);
+
+ return;
+ }
+
+ $names = $logs->pluck('log_name')->unique()->toArray();
+ $text = 'Logs having new records for the last '.$options['alert_logs_period'].':';
+ foreach ($names as $name) {
+ $text .= '
';
+
+ foreach ($names as $name) {
+ $text .= '
'.\App\ActivityLog::getLogTitle($name).'
';
+ foreach ($logs as $log) {
+ if ($log->log_name != $name) {
+ continue;
+ }
+ $text .= '● ['.$log->created_at.'] '.$log->getEventDescription().' '.$log->properties.'
';
+ }
+ }
+ // Send alert.
+ \MailHelper::sendAlertMail($text, 'Logs Monitoring');
+
+ $this->line($text);
+
+ $this->info('['.date('Y-m-d H:i:s').'] Monitoring finished');
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/ModuleBuild.php b/freescout-dist/app/Console/Commands/ModuleBuild.php
new file mode 100644
index 0000000..1abd139
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/ModuleBuild.php
@@ -0,0 +1,118 @@
+argument('module_alias');
+ if (!$module_alias) {
+ $modules = \Module::all();
+
+ $modules_aliases = [];
+ foreach ($modules as $module) {
+ $modules_aliases[] = $module->name;
+ }
+ if (!$modules_aliases) {
+ $this->error('No modules found');
+
+ return;
+ }
+ $all = true;
+ // $all = $this->confirm('You have not specified a module alias, would you like to build all available modules ('.implode(', ', $modules_aliases).')?');
+ // if (!$all) {
+ // return;
+ // }
+ }
+
+ if ($all) {
+ foreach ($modules as $module) {
+ $this->buildModule($module);
+ $this->call('freescout:module-laroute', ['module_alias' => $module->getAlias()]);
+ }
+ } else {
+ $module = \Module::findByAlias($module_alias);
+ if (!$module) {
+ $this->error('Module with the specified alias not found: '.$module_alias);
+
+ return;
+ }
+ $this->buildModule($module);
+ $this->call('freescout:module-laroute');
+ }
+ }
+
+ public function buildModule($module)
+ {
+ $this->line('Module: '.$module->getName());
+
+ $public_symlink = public_path('modules').DIRECTORY_SEPARATOR.$module->alias;
+ if (!file_exists($public_symlink)) {
+ $this->error('Public symlink ['.$public_symlink.'] not found. Run module installation command first: php artisan freescout:module-install');
+
+ return;
+ }
+
+ $this->buildVars($module);
+ }
+
+ public function buildVars($module)
+ {
+ try {
+ $params = [
+ 'locales' => config('app.locales'),
+ ];
+
+ $filesystem = new Filesystem();
+
+ $file_path = public_path('modules/'.$module->alias.'/js/vars.js');
+
+ $compiled = view($module->alias.'::js/vars', $params)->render();
+
+ if ($compiled) {
+ $filesystem->put($file_path, $compiled);
+ }
+
+ $this->info("Created: {$file_path}");
+ } catch (\Exception $e) {
+ $this->error($e->getMessage());
+ }
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/ModuleCheckLicenses.php b/freescout-dist/app/Console/Commands/ModuleCheckLicenses.php
new file mode 100644
index 0000000..13480c0
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/ModuleCheckLicenses.php
@@ -0,0 +1,102 @@
+info('Active modules found: '.count($modules));
+
+ $params = [
+ 'url' => \App\Module::getAppUrl(),
+ 'data' => [],
+ ];
+
+ foreach ($modules as $module) {
+ $license = $module->getLicense();
+
+ if (!$module->isOfficial() || !$license) {
+ continue;
+ }
+
+ $data['license'] = $license;
+ $data['module_alias'] = $module->getAlias();
+
+ $params['data'][] = $data;
+ }
+
+ $result = WpApi::checkLicenses($params);
+
+ if (!empty($result['statuses'])) {
+ foreach ($modules as $module) {
+ $module_alias = $module->getAlias();
+
+ foreach ($result['statuses'] as $result_module_alias => $status) {
+ if ($result_module_alias != $module_alias) {
+ continue;
+ }
+ if (!empty($status) && $status != 'valid') {
+ $msg = 'Module '.$module->getName().' has been deactivated due to invalid license: '.$status;
+
+ $this->error($module->getName().': '.$msg);
+
+ // Deactive module
+ \App\Module::deactiveModule($module->getAlias(), true);
+
+ // Inform admin
+ \Log::error($msg);
+ activity()
+ ->withProperties([
+ 'error' => $msg,
+ ])
+ ->useLog(\App\ActivityLog::NAME_SYSTEM)
+ ->log(\App\ActivityLog::DESCRIPTION_SYSTEM_ERROR);
+ } else {
+ $this->info($module->getName().': OK');
+ }
+ continue 2;
+ }
+
+ $this->info($module->getName().': Unknown status');
+ }
+ }
+
+ $this->info('Checking licenses finished');
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/ModuleInstall.php b/freescout-dist/app/Console/Commands/ModuleInstall.php
new file mode 100644
index 0000000..e95c2e0
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/ModuleInstall.php
@@ -0,0 +1,140 @@
+call('cache:clear');
+
+ // Create a symlink for the module (or all modules)
+ $module_alias = $this->argument('module_alias');
+ if (!$module_alias) {
+ $modules = \Module::all();
+
+ $modules_aliases = [];
+ foreach ($modules as $module) {
+ $modules_aliases[] = $module->name;
+ }
+ if (!$modules_aliases) {
+ $this->error('No modules found');
+
+ return;
+ }
+ $install_all = $this->confirm('You have not specified a module alias, would you like to install all available modules ('.implode(', ', $modules_aliases).')?');
+ if (!$install_all) {
+ return;
+ }
+ }
+
+ if ($install_all) {
+ foreach ($modules as $module) {
+ $this->line('Module: '.$module->getName());
+ $this->call('module:migrate', ['module' => $module->getName()]);
+ $this->createModulePublicSymlink($module);
+ }
+ } else {
+ $module = \Module::findByAlias($module_alias);
+ if (!$module) {
+ $this->error('Module with the specified alias not found: '.$module_alias);
+
+ return;
+ }
+ $this->call('module:migrate', ['module' => $module->getName(), '--force' => true]);
+ $this->createModulePublicSymlink($module);
+ }
+ $this->line('Clearing cache...');
+ $this->call('freescout:clear-cache');
+ }
+
+ // There is similar function in \App\Module.
+ public function createModulePublicSymlink($module)
+ {
+ $from = public_path('modules').DIRECTORY_SEPARATOR.$module->alias;
+ $to = $module->getExtraPath('Public');
+
+ // file_exists() may throw "open_basedir restriction in effect".
+ try {
+ // If module's Public is symlink.
+ if (is_link($to)) {
+ @unlink($to);
+ }
+
+ // Symlimk may exist but lead to the module folder in a wrong case.
+ // So we need first try to remove it.
+ if (!file_exists($from) || !is_link($from)) {
+ if (is_dir($from)) {
+ @rename($from, $from.'_'.date('YmdHis'));
+ } else {
+ @unlink($from);
+ }
+ }
+
+ if (file_exists($from)) {
+ return $this->info('Public symlink already exists');
+ }
+
+ // Check target.
+ if (!file_exists($to)) {
+ // Try to create Public folder.
+ try {
+ \File::makeDirectory($to, \Helper::DIR_PERMISSIONS);
+ } catch (\Exception $e) {
+ // If it's a broken symlink.
+ if (is_link($to)) {
+ @unlink($to);
+ }
+ }
+ }
+
+ try {
+ symlink($to, $from);
+ } catch (\Exception $e) {
+ $this->error('Error occurred creating ['.$from.' » '.$to.'] symlink: '.$e->getMessage());
+ }
+ } catch (\Exception $e) {
+ $this->error('Error occurred creating ['.$from.' » '.$to.'] symlink: '.$e->getMessage());
+ }
+
+ $this->info('The ['.$from.'] symlink has been created');
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/ModuleLaroute.php b/freescout-dist/app/Console/Commands/ModuleLaroute.php
new file mode 100644
index 0000000..12657e5
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/ModuleLaroute.php
@@ -0,0 +1,171 @@
+config = $app['config'];
+ $this->generator = $app->make('Lord\Laroute\Generators\GeneratorInterface');
+
+ parent::__construct();
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $all = false;
+ $modules = [];
+
+ // Create a symlink for the module (or all modules)
+ $module_alias = $this->argument('module_alias');
+ if (!$module_alias) {
+ $modules = \Module::all();
+
+ $modules_aliases = [];
+ foreach ($modules as $module) {
+ $modules_aliases[] = $module->name;
+ }
+ if (!$modules_aliases) {
+ $this->error('No modules found');
+
+ return;
+ }
+ $all = true;
+ // $all = $this->confirm('You have not specified a module alias, would you like to generate routes for all available modules ('.implode(', ', $modules_aliases).')?');
+ // if (!$all) {
+ // return;
+ // }
+ }
+
+ if ($all) {
+ foreach ($modules as $module) {
+ $this->generateModuleRoutes($module);
+ }
+ } else {
+ $module = \Module::findByAlias($module_alias);
+ if (!$module) {
+ $this->error('Module with the specified alias not found: '.$module_alias);
+
+ return;
+ }
+ $this->generateModuleRoutes($module);
+ }
+ }
+
+ public function generateModuleRoutes($module)
+ {
+ $this->line('Module: '.$module->getName());
+
+ $public_symlink = public_path('modules').DIRECTORY_SEPARATOR.$module->getAlias();
+ if (!file_exists($public_symlink)) {
+ $this->error('Public symlink ['.$public_symlink.'] not found. Run module installation command first: php artisan freescout:module-install');
+
+ return;
+ }
+
+ $this->routes = new Routes(app()['router']->getRoutes(), $this->config->get('laroute.filter', 'all'), $this->config->get('laroute.action_namespace', ''), $module->getAlias());
+
+ try {
+ $filePath = $this->generator->compile(
+ $this->getTemplatePath(),
+ $this->getTemplateData(),
+ $this->getFileGenerationPath($module->getAlias())
+ );
+
+ $this->info("Created: {$filePath}");
+ } catch (\Exception $e) {
+ $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * Get path to the template file.
+ *
+ * @return string
+ */
+ protected function getTemplatePath()
+ {
+ return 'resources/assets/js/laroute_module.js';
+ }
+
+ /**
+ * Get the data for the template.
+ *
+ * @return array
+ */
+ protected function getTemplateData()
+ {
+ $namespace = $this->getOptionOrConfig('namespace');
+ $routes = $this->routes->toJSON();
+ $absolute = $this->config->get('laroute.absolute', false);
+ $rootUrl = $this->config->get('app.url', '');
+ $prefix = $this->config->get('laroute.prefix', '');
+
+ return compact('namespace', 'routes', 'absolute', 'rootUrl', 'prefix');
+ }
+
+ /**
+ * Get the path where the file will be generated.
+ *
+ * @return string
+ */
+ protected function getFileGenerationPath($module_alias)
+ {
+ $path = 'public/modules/'.$module_alias.'/js';
+ $filename = 'laroute'; //$this->getOptionOrConfig('filename');
+
+ return "{$path}/{$filename}.js";
+ }
+
+ /**
+ * Get an option value either from console input, or the config files.
+ *
+ * @param $key
+ *
+ * @return array|mixed|string
+ */
+ protected function getOptionOrConfig($key)
+ {
+ // if ($option = $this->option($key)) {
+ // return $option;
+ // }
+
+ return $this->config->get("laroute.{$key}");
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/ModuleUpdate.php b/freescout-dist/app/Console/Commands/ModuleUpdate.php
new file mode 100644
index 0000000..1bf3957
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/ModuleUpdate.php
@@ -0,0 +1,105 @@
+argument('module_alias');
+
+ $modules_directory = \WpApi::getModules();
+ if (\WpApi::$lastError) {
+ $this->error(__('Error occurred').': '.$lastError['message'].' ('.$lastError['code'].')');
+ return;
+ }
+
+ $installed_modules = \Module::all();
+
+ $counter = 0;
+ $found = false;
+ foreach ($modules_directory as $dir_module) {
+ // Update single module.
+ if ($module_alias && $dir_module['alias'] != $module_alias) {
+ continue;
+ }
+
+ $found = true;
+
+ // Detect if new version is available.
+ foreach ($installed_modules as $module) {
+ if ($module->getAlias() != $dir_module['alias'] || !$module->active()) {
+ continue;
+ }
+ if (!empty($dir_module['version']) && version_compare($dir_module['version'], $module->get('version'), '>')) {
+ $update_result = \App\Module::updateModule($dir_module['alias']);
+
+ $this->info('['.$update_result['module_name'].' Module'.']');
+ if ($update_result['status'] == 'success') {
+ $this->line($update_result['msg_success']);
+ } else {
+ $msg = $update_result['msg'];
+ if ($update_result['download_msg']) {
+ $msg .= ' ('.$update_result['download_msg'].')';
+ }
+ $this->error('ERROR: '.$msg);
+ }
+ if (trim($update_result['output'])) {
+ $this->line(preg_replace("#\n#", "\n> ", '> '.trim($update_result['output'])));
+ }
+
+ $counter++;
+ }
+ }
+ }
+
+ if ($module_alias && !$found) {
+ $this->error('Module with the following alias not found: '.$module_alias);
+ } elseif (!$counter) {
+ $this->line('All modules are up-to-date');
+ }
+
+ \Artisan::call('freescout:clear-cache');
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/SendMonitor.php b/freescout-dist/app/Console/Commands/SendMonitor.php
new file mode 100644
index 0000000..df88ad3
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/SendMonitor.php
@@ -0,0 +1,66 @@
+where('payload', 'like', '{"displayName":"App\\\\\\\\Jobs\\\\\\\\SendReplyToCustomer"%')
+ ->where('available_at', '<', time() - self::CHECK_PERIOD)
+ ->exists();
+
+ // Check failed_jobs.
+ // No need - it can be done via Manage > Alerts > Logs Monitoring
+ // if (!$pending_jobs) {
+ // $pending_jobs = \App\FailedJob::where('queue', 'emails')
+ // ->where('payload', 'like', '{"displayName":"App\\\\\\\\Jobs\\\\\\\\SendReplyToCustomer"%')
+ // ->where('created_at', '<', time() - self::CHECK_PERIOD)
+ // ->exists();
+ // }
+
+ if ($pending_jobs) {
+ \Option::set('send_emails_problem', '1');
+ $this->error('['.date('Y-m-d H:i:s').'] There are problems with emails queue processing');
+ } else {
+ \Option::remove('send_emails_problem');
+ $this->info('['.date('Y-m-d H:i:s').'] Emails queue processing is working');
+ }
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/Update.php b/freescout-dist/app/Console/Commands/Update.php
new file mode 100644
index 0000000..cc904c4
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/Update.php
@@ -0,0 +1,64 @@
+confirmToProceed()) {
+ return;
+ }
+
+ @ini_set('memory_limit', '128M');
+
+ if (\Updater::isNewVersionAvailable(config('app.version'))) {
+ $this->info('Updating... This may take several minutes');
+
+ try {
+ // Script may fail here and stop with the error:
+ // PHP Fatal error: Allowed memory size of 94371840 bytes exhausted
+ \Updater::update();
+ $this->call('freescout:after-app-update');
+ } catch (\Exception $e) {
+ $this->error('Error occurred: '.$e->getMessage());
+ }
+ } else {
+ $this->info('You have the latest version installed: '.config('app.version'));
+ }
+ }
+}
diff --git a/freescout-dist/app/Console/Commands/UpdateFolderCounters.php b/freescout-dist/app/Console/Commands/UpdateFolderCounters.php
new file mode 100644
index 0000000..58b35bf
--- /dev/null
+++ b/freescout-dist/app/Console/Commands/UpdateFolderCounters.php
@@ -0,0 +1,46 @@
+updateCounters();
+ $this->line('Updated counters for folder: '.$folder->id);
+ }
+ $this->info('Updating finished');
+ }
+}
diff --git a/freescout-dist/app/Console/Kernel.php b/freescout-dist/app/Console/Kernel.php
new file mode 100644
index 0000000..fdbe628
--- /dev/null
+++ b/freescout-dist/app/Console/Kernel.php
@@ -0,0 +1,273 @@
+command('queue:flush')
+ ->weekly();
+
+ // Restart processing queued jobs (just in case)
+ $schedule->command('queue:restart')
+ ->hourly();
+
+ $schedule->command('freescout:fetch-monitor')
+ ->everyMinute()
+ ->withoutOverlapping();
+
+ $schedule->command('freescout:send-monitor')
+ // Every 10 minutes.
+ ->cron('*/10 * * * *')
+ ->withoutOverlapping();
+
+ $schedule->command('freescout:update-folder-counters')
+ ->hourly();
+
+ $app_key = config('app.key');
+ if ($app_key) {
+ $crc = crc32($app_key);
+ $schedule->command('freescout:module-check-licenses')
+ ->cron((int)($crc % 59).' '.(int)($crc % 23).' * * *');
+ }
+
+ // Check if user finished viewing conversation.
+ $schedule->command('freescout:check-conv-viewers')
+ ->everyMinute()
+ ->withoutOverlapping();
+
+ $schedule->command('freescout:clean-send-log')
+ ->monthly();
+
+ $schedule->command('freescout:clean-notifications-table')
+ ->weekly();
+
+ $schedule->command('freescout:clean-tmp')
+ ->daily();
+
+ // Logs monitoring.
+ $alert_logs_period = config('app.alert_logs_period');
+ if (config('app.alert_logs') && $alert_logs_period) {
+ $logs_cron = '';
+ switch ($alert_logs_period) {
+ case 'hour':
+ $logs_cron = '0 * * * *';
+ break;
+ case 'day':
+ $logs_cron = '0 0 * * *';
+ break;
+ case 'week':
+ $logs_cron = '0 0 * * 0';
+ break;
+ case 'month':
+ $logs_cron = '0 0 1 * *';
+ break;
+ }
+ if ($logs_cron) {
+ $schedule->command('freescout:logs-monitor')
+ ->cron($logs_cron)
+ ->withoutOverlapping();
+ }
+ }
+
+ $fetch_command_identifier = \Helper::getWorkerIdentifier('freescout:fetch-emails');
+ $fetch_command_name = 'freescout:fetch-emails --identifier='.$fetch_command_identifier;
+
+ // Kill fetch commands running for too long.
+ // In shedule:run this code is executed every time $schedule->command() in this function is executed.
+ if ($this->isScheduleRun() && function_exists('shell_exec')) {
+ $fetch_command_pids = \Helper::getRunningProcesses($fetch_command_identifier);
+
+ // The name of the command here must be exactly the same as below!
+ // Otherwise long fetching will be killed and won't run longer than 1 mintue.
+ $mutex_name = $schedule->command($fetch_command_name)
+ ->skip(function () {
+ return true;
+ })
+ ->mutexName();
+
+ // If there is no cache mutext but there are running fetch commands
+ // it means the mutex had expired after self::FETCH_MAX_EXECUTION_TIME
+ // and the existing command(s) is running longer than self::FETCH_MAX_EXECUTION_TIME.
+ if (count($fetch_command_pids) > 0 && !\Cache::get($mutex_name)) {
+ // Kill freescout:fetch-emails commands running for too long
+ shell_exec('kill '.implode(' | kill ', $fetch_command_pids));
+ } elseif (count($fetch_command_pids) == 0) {
+ // Make sure 'ps' command actually works.
+ $ps_works = \Helper::getRunningProcesses('schedule:run');
+
+ if (count($ps_works)) {
+ // Previous freescout:fetch-emails may have been killed or errored and did not remove the mutex.
+ // So here we are forcefully removing the mutex. Otherwise mutex will live for 24 hours.
+ if (\Cache::has($mutex_name)) {
+ \Cache::forget($mutex_name);
+ }
+ }
+ }
+ }
+
+ // Fetch emails from mailboxes
+ $fetch_command = $schedule->command($fetch_command_name)
+ // withoutOverlapping() option creates a mutex in the cache
+ // which by default expires in 24 hours.
+ // So we are passing an 'expiresAt' parameter to withoutOverlapping() to
+ // prevent fetching from not being executed when fetching command by some reason
+ // does not remove the mutex from the cache.
+ ->withoutOverlapping($expiresAt = self::FETCH_MAX_EXECUTION_TIME /* minutes */)
+ ->sendOutputTo(storage_path().'/logs/fetch-emails.log');
+
+ switch (config('app.fetch_schedule')) {
+ case Mail::FETCH_SCHEDULE_EVERY_FIVE_MINUTES:
+ $fetch_command->everyFiveMinutes();
+ break;
+ case Mail::FETCH_SCHEDULE_EVERY_TEN_MINUTES:
+ $fetch_command->everyTenMinutes();
+ break;
+ case Mail::FETCH_SCHEDULE_EVERY_FIFTEEN_MINUTES:
+ $fetch_command->everyFifteenMinutes();
+ break;
+ case Mail::FETCH_SCHEDULE_EVERY_THIRTY_MINUTES:
+ $fetch_command->everyThirtyMinutes();
+ break;
+ case Mail::FETCH_SCHEDULE_HOURLY:
+ $fetch_command->Hourly();
+ break;
+ default:
+ $fetch_command->everyMinute();
+ break;
+ }
+
+ $schedule = \Eventy::filter('schedule', $schedule);
+
+ // If --no-daemonize flag is passed - do not run 'queue:work' daemon.
+ foreach ($_SERVER['argv'] ?? [] as $arg) {
+ if ($arg == '--no-interaction') {
+ return;
+ }
+ }
+
+ // Command runs as subprocess and sets cache mutex. If schedule:run command is killed
+ // subprocess does not clear the mutex and it stays in the cache until cache:clear is executed.
+ // By default, the lock will expire after 24 hours.
+
+ $queue_work_params = Config('app.queue_work_params');
+ // Add identifier to avoid conflicts with other FreeScout instances on the same server.
+ $queue_work_params['--queue'] .= ','.\Helper::getWorkerIdentifier();
+
+ // $schedule->command('queue:work') command below has withoutOverlapping() option,
+ // which works via special mutex stored in the cache preventing several 'queue:work' to work at the same time.
+ // So when the cache is cleared the mutex indicating that the 'queue:work' is running is removed,
+ // and the second 'queue:work' command is launched by cron. When `artisan schedule:run` is executed it sees
+ // that there are two 'queue:work' processes running and kills them.
+ // After one minute 'queue:work' is executed by cron via `artisan schedule:run` and works in the background.
+ if ($this->isScheduleRun() && function_exists('shell_exec')) {
+ $running_commands = \Helper::getRunningProcesses();
+
+ if (count($running_commands) > 1) {
+ // Stop all queue:work processes.
+ // queue:work command is stopped by settings a cache key
+ \Helper::queueWorkerRestart();
+ // Sometimes processes stuck and just continue running, so we need to kill them.
+ // Sleep to let processes stop.
+ sleep(1);
+ // Check processes again.
+ $worker_pids = \Helper::getRunningProcesses();
+
+ if (count($worker_pids) > 1) {
+ // Current process also has to be killed, as otherwise it "stucks"
+ // $current_pid = getmypid();
+ // foreach ($worker_pids as $i => $pid) {
+ // if ($pid == $current_pid) {
+ // unset($worker_pids[$i]);
+ // break;
+ // }
+ // }
+ shell_exec('kill '.implode(' | kill ', $worker_pids));
+ }
+ } elseif (count($running_commands) == 0) {
+ // Make sure 'ps' command actually works.
+ $ps_works = \Helper::getRunningProcesses('schedule:run');
+
+ if (count($ps_works)) {
+ // Previous queue:work may have been killed or errored and did not remove the mutex.
+ // So here we are forcefully removing the mutex.
+ $mutex_name = $schedule->command('queue:work', $queue_work_params)
+ ->skip(function () {
+ return true;
+ })
+ ->mutexName();
+ if (\Cache::get($mutex_name)) {
+ \Cache::forget($mutex_name);
+ }
+ }
+ }
+ }
+
+ $schedule->command('queue:work', $queue_work_params)
+ ->everyMinute()
+ ->withoutOverlapping()
+ ->sendOutputTo(storage_path().'/logs/queue-jobs.log');
+ }
+
+ /**
+ * This function is needed because every time $schedule->command() is executed
+ * the schedule() is executed also.
+ */
+ public function isScheduleRun()
+ {
+ if (!\Helper::isConsole()) {
+ return true;
+ } else {
+ return !empty($_SERVER['argv']) && in_array('schedule:run', $_SERVER['argv']);
+ }
+ }
+
+ /**
+ * Register the commands for the application.
+ *
+ * @return void
+ */
+ protected function commands()
+ {
+ $this->load(__DIR__.'/Commands');
+
+ // Swiftmailer uses $_SERVER['SERVER_NAME'] in transport_deps.php
+ // to set the host for EHLO command, if it is empty it uses [127.0.0.1].
+ // G Suite sometimes rejects emails with EHLO [127.0.0.1].
+ if (empty($_SERVER['SERVER_NAME'])) {
+ $_SERVER['SERVER_NAME'] = parse_url(config('app.url'), PHP_URL_HOST);
+ }
+
+ require base_path('routes/console.php');
+ }
+}
diff --git a/freescout-dist/app/Conversation.php b/freescout-dist/app/Conversation.php
new file mode 100644
index 0000000..2bca11c
--- /dev/null
+++ b/freescout-dist/app/Conversation.php
@@ -0,0 +1,2424 @@
+ 'customer',
+ self::PERSON_USER => 'user',
+ ];
+
+ /**
+ * Conversation types.
+ */
+ const TYPE_EMAIL = 1;
+ const TYPE_PHONE = 2;
+ const TYPE_CHAT = 3;
+
+ public static $types = [
+ self::TYPE_EMAIL => 'email',
+ self::TYPE_PHONE => 'phone',
+ self::TYPE_CHAT => 'chat',
+ ];
+
+ /**
+ * Conversation statuses (code must be equal to thread statuses).
+ */
+ const STATUS_ACTIVE = 1;
+ const STATUS_PENDING = 2;
+ const STATUS_CLOSED = 3;
+ const STATUS_SPAM = 4;
+ // Not used
+ //const STATUS_OPEN = 5;
+
+ public static $statuses = [
+ self::STATUS_ACTIVE => 'active',
+ self::STATUS_PENDING => 'pending',
+ self::STATUS_CLOSED => 'closed',
+ self::STATUS_SPAM => 'spam',
+ //self::STATUS_OPEN => 'open',
+ ];
+
+ /**
+ * https://glyphicons.bootstrapcheatsheets.com/.
+ */
+ public static $status_icons = [
+ self::STATUS_ACTIVE => 'flag',
+ self::STATUS_PENDING => 'ok',
+ self::STATUS_CLOSED => 'lock',
+ self::STATUS_SPAM => 'ban-circle',
+ //self::STATUS_OPEN => 'folder-open',
+ ];
+
+ public static $status_classes = [
+ self::STATUS_ACTIVE => 'success',
+ self::STATUS_PENDING => 'lightgrey',
+ self::STATUS_CLOSED => 'grey',
+ self::STATUS_SPAM => 'danger',
+ //self::STATUS_OPEN => 'folder-open',
+ ];
+
+ public static $status_colors = [
+ self::STATUS_ACTIVE => '#6ac27b',
+ self::STATUS_PENDING => '#8b98a6',
+ self::STATUS_CLOSED => '#6b6b6b',
+ self::STATUS_SPAM => '#de6864',
+ ];
+
+ /**
+ * Conversation states.
+ */
+ const STATE_DRAFT = 1;
+ const STATE_PUBLISHED = 2;
+ const STATE_DELETED = 3;
+
+ public static $states = [
+ self::STATE_DRAFT => 'draft',
+ self::STATE_PUBLISHED => 'published',
+ self::STATE_DELETED => 'deleted',
+ ];
+
+ /**
+ * Source types (equal to thread source types).
+ */
+ const SOURCE_TYPE_EMAIL = 1;
+ const SOURCE_TYPE_WEB = 2;
+ const SOURCE_TYPE_API = 3;
+
+ public static $source_types = [
+ self::SOURCE_TYPE_EMAIL => 'email',
+ self::SOURCE_TYPE_WEB => 'web',
+ self::SOURCE_TYPE_API => 'api',
+ ];
+
+ /**
+ * Email history options.
+ */
+ // const EMAIL_HISTORY_GLOBAL = 0;
+ // const EMAIL_HISTORY_NONE = 1;
+ // const EMAIL_HISTORY_LAST = 2;
+ // const EMAIL_HISTORY_FULL = 3;
+
+ public static $email_history_codes = [
+ 'global',
+ 'none',
+ 'last',
+ 'full',
+ ];
+
+ /**
+ * Assignee.
+ */
+ const USER_UNASSIGNED = -1;
+
+ /**
+ * Search filters.
+ */
+ public static $search_filters = [
+ 'assigned',
+ 'customer',
+ 'mailbox',
+ 'status',
+ 'state',
+ 'subject',
+ 'attachments',
+ 'type',
+ 'body',
+ 'number',
+ 'following',
+ 'id',
+ 'after',
+ 'before',
+ //'between',
+ //'on',
+ ];
+
+ /**
+ * Search mode.
+ */
+ const SEARCH_MODE_CONV = 'conversations';
+ const SEARCH_MODE_CUSTOMERS = 'customers';
+
+ /**
+ * Default size of the conversations table.
+ */
+ const DEFAULT_LIST_SIZE = 50;
+
+ /**
+ * Default size of the chats list.
+ */
+ const CHATS_LIST_SIZE = 50;
+
+ /**
+ * Cache of the conversations starred by user.
+ *
+ * @var array
+ */
+ public static $starred_conversation_ids = [];
+
+ /**
+ * Cache of the app.custom_number option.
+ */
+ public static $custom_number_cache = null;
+
+ /**
+ * Automatically converted into Carbon dates.
+ */
+ protected $dates = ['created_at', 'updated_at', 'last_reply_at', 'closed_at', 'user_updated_at'];
+
+ /**
+ * Attributes which are not fillable using fill() method.
+ */
+ protected $guarded = ['id', 'folder_id'];
+
+ /**
+ * Convert to array.
+ */
+ protected $casts = [
+ 'meta' => 'array',
+ ];
+
+ /**
+ * Default values.
+ */
+ protected $attributes = [
+ 'preview' => '',
+ ];
+
+ protected static function boot()
+ {
+ parent::boot();
+
+ self::creating(function (Conversation $model) {
+ $next_ticket = (int) Option::get('next_ticket');
+ $current_number = Conversation::max('number');
+
+ if ($next_ticket) {
+ Option::remove('next_ticket');
+ }
+
+ if ($next_ticket && $next_ticket >= ($current_number + 1) && !Conversation::where('number', $next_ticket)->exists()) {
+ $model->number = $next_ticket;
+ } else {
+ $model->number = $current_number + 1;
+ }
+ });
+ }
+
+ /**
+ * Who the conversation is assigned to (assignee).
+ */
+ public function user()
+ {
+ return $this->belongsTo('App\User');
+ }
+
+ /**
+ * Get the folder to which conversation belongs via folder field.
+ */
+ public function folder()
+ {
+ return $this->belongsTo('App\Folder');
+ }
+
+ /**
+ * Get the folder to which conversation belongs via conversation_folder table.
+ */
+ public function folders()
+ {
+ return $this->belongsToMany('App\Folder');
+ }
+
+ /**
+ * Get the mailbox to which conversation belongs.
+ */
+ public function mailbox()
+ {
+ return $this->belongsTo('App\Mailbox');
+ }
+
+ /**
+ * Cached mailbox.
+ * @return [type] [description]
+ */
+ public function mailbox_cached()
+ {
+ return $this->mailbox()->rememberForever();
+ }
+
+ /**
+ * Get the customer associated with this conversation (primaryCustomer).
+ */
+ public function customer()
+ {
+ return $this->belongsTo('App\Customer');
+ }
+
+ /**
+ * Cached customer.
+ */
+ public function customer_cached()
+ {
+ return $this->customer()->rememberForever();
+ }
+
+ /**
+ * Get conversation threads.
+ */
+ public function threads()
+ {
+ return $this->hasMany('App\Thread');
+ }
+
+ /**
+ * Folders containing starred conversations.
+ */
+ public function extraFolders()
+ {
+ return $this->belongsTo('App\Customer');
+ }
+
+ /**
+ * Get user who created the conversations.
+ */
+ public function created_by_user()
+ {
+ return $this->belongsTo('App\User');
+ }
+
+ /**
+ * Get customer who created the conversations.
+ */
+ public function created_by_customer()
+ {
+ return $this->belongsTo('App\Customer');
+ }
+
+ /**
+ * Get user who closed the conversations.
+ */
+ public function closed_by_user()
+ {
+ return $this->belongsTo('App\User');
+ }
+
+ /**
+ * Get conversations followers.
+ */
+ public function followers()
+ {
+ return $this->hasMany('App\Follower');
+ }
+
+ /**
+ * Check if user is following this conversation.
+ */
+ public function isUserFollowing($user_id)
+ {
+ // We intentionally select all records from followers table,
+ // as it is more efficient than querying a particular user record.
+ foreach ($this->followers as $follower) {
+ if ($follower->user_id == $user_id) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get only reply threads from conversations.
+ *
+ * @return Collection
+ */
+ public function getReplies()
+ {
+ return $this->threads()
+ ->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE])
+ ->where('state', Thread::STATE_PUBLISHED)
+ ->orderBy('created_at', 'desc')
+ ->get();
+ }
+
+ /**
+ * Get all published conversation threads in desc order.
+ *
+ * @return Collection
+ */
+ public function getThreads($skip = null, $take = null, $types = [])
+ {
+ $query = $this->threads()
+ ->where('state', Thread::STATE_PUBLISHED)
+ ->orderBy('created_at', 'desc');
+
+ if (!is_null($skip)) {
+ $query->skip($skip);
+ }
+ if (!is_null($take)) {
+ $query->take($take);
+ }
+ if ($types) {
+ $query->whereIn('type', $types);
+ }
+
+ return $query->get();
+ }
+
+ /**
+ * Get first thread of the conversation.
+ */
+ public function getFirstThread()
+ {
+ return $this->threads()
+ ->orderBy('created_at', 'asc')
+ ->first();
+ }
+
+ /**
+ * Get last reply by customer or support agent.
+ *
+ * @param bool $last [description]
+ *
+ * @return [type] [description]
+ */
+ public function getLastReply($include_phone_replies = false)
+ {
+ $types = [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE];
+ if ($include_phone_replies && $this->isPhone()) {
+ $types[] = Thread::TYPE_NOTE;
+ }
+ return $this->threads()
+ ->whereIn('type', $types)
+ ->where('state', Thread::STATE_PUBLISHED)
+ ->orderBy('created_at', 'desc')
+ ->first();
+ }
+
+ /**
+ * Get last thread by type.
+ */
+ public function getLastThread($types = [])
+ {
+ $query = $this->threads()
+ ->where('state', Thread::STATE_PUBLISHED)
+ ->orderBy('created_at', 'desc');
+ if ($types) {
+ if (count($types) == 1 && $types[0]) {
+ $query->where('type', $types[0]);
+ } else {
+ $query->whereIn('type', $types);
+ }
+ }
+ return $query->first();
+ }
+
+ /**
+ * Set preview text.
+ *
+ * @param string $text
+ */
+ public function setPreview($text = '')
+ {
+ $this->preview = '';
+
+ if (!$text) {
+ $first_thread = $this->threads()->first();
+ if ($first_thread) {
+ $text = $first_thread->body;
+ }
+ }
+
+ $this->preview = \Helper::textPreview($text, self::PREVIEW_MAXLENGTH);
+
+ return $this->preview;
+ }
+
+ /**
+ * Get conversation timestamp title.
+ *
+ * @return string
+ */
+ public function getDateTitle()
+ {
+ if ($this->threads_count == 1) {
+ $title = __('Created by :person', ['person' => __(ucfirst(self::$persons[$this->source_via]))]);
+ $title .= '
'.User::dateFormat($this->created_at, 'M j, Y H:i');
+ } else {
+ $person = '';
+ if (!empty(self::$persons[$this->last_reply_from])) {
+ $person = __(ucfirst(self::$persons[$this->last_reply_from]));
+ }
+ $title = __('Last reply by :person', ['person' => $person]);
+ $last_reply_at = $this->created_at;
+ if ($this->last_reply_at) {
+ $last_reply_at = $this->last_reply_at;
+ }
+ $title .= '
'.User::dateFormat($last_reply_at, 'M j, Y H:i');
+ }
+
+ return $title;
+ }
+
+ public function isActive()
+ {
+ return $this->status == self::STATUS_ACTIVE;
+ }
+
+ public function isPending()
+ {
+ return $this->status == self::STATUS_PENDING;
+ }
+
+ public function isSpam()
+ {
+ return $this->status == self::STATUS_SPAM;
+ }
+
+ public function isClosed()
+ {
+ return $this->status == self::STATUS_CLOSED;
+ }
+
+ public function isPublished()
+ {
+ return $this->state == self::STATE_PUBLISHED;
+ }
+
+ public function isDraft()
+ {
+ return $this->state == self::STATE_DRAFT;
+ }
+
+ /**
+ * Get status name.
+ *
+ * @return string
+ */
+ public function getStatusName()
+ {
+ return self::statusCodeToName($this->status);
+ }
+
+ /**
+ * Convert status code to name.
+ *
+ * @param int $status
+ *
+ * @return string
+ */
+ public static function statusCodeToName($status)
+ {
+ switch ($status) {
+ case self::STATUS_ACTIVE:
+ return __('Active');
+ break;
+
+ case self::STATUS_PENDING:
+ return __('Pending');
+ break;
+
+ case self::STATUS_CLOSED:
+ return __('Closed');
+ break;
+
+ case self::STATUS_SPAM:
+ return __('Spam');
+ break;
+
+ // case self::STATUS_OPEN:
+ // return __('Open');
+ // break;
+
+ default:
+ return '';
+ break;
+ }
+ }
+
+ /**
+ * Convert state code to name.
+ *
+ * @param int $status
+ *
+ * @return string
+ */
+ public static function stateCodeToName($status)
+ {
+ switch ($status) {
+ case self::STATE_DRAFT:
+ return __('Draft');
+ break;
+
+ case self::STATE_PUBLISHED:
+ return __('Published');
+ break;
+
+ case self::STATE_DELETED:
+ return __('Deleted');
+ break;
+
+ default:
+ return '';
+ break;
+ }
+ }
+
+ public function getStatus()
+ {
+ if (array_key_exists($this->status, self::$statuses)) {
+ return $this->status;
+ } else {
+ return self::STATUS_ACTIVE;
+ }
+ }
+
+ /**
+ * Set conversation status and all related fields.
+ *
+ * @param int $status
+ */
+ public function setStatus($status, $user = null)
+ {
+ $now = date('Y-m-d H:i:s');
+
+ $this->status = $status;
+ $this->updateFolder();
+ $this->user_updated_at = $now;
+
+ if ($user && $status == self::STATUS_CLOSED) {
+ $this->closed_by_user_id = $user->id;
+ $this->closed_at = $now;
+ }
+ }
+
+ /**
+ * Set conversation user and all related fields.
+ *
+ * @param int $user_id
+ */
+ public function setUser($user_id)
+ {
+ $now = date('Y-m-d H:i:s');
+
+ if ($user_id == -1) {
+ $user_id = null;
+ }
+
+ $this->user_id = $user_id;
+ $this->updateFolder();
+ $this->user_updated_at = $now;
+
+ // If user was previously following the conversation then unfollow
+ if (!is_null($user_id)) {
+ $follower = Follower::where('conversation_id', $this->id)
+ ->where('user_id', $user_id)
+ ->first();
+ if ($follower) {
+ $follower->delete();
+ }
+ }
+ }
+
+ /**
+ * Get next active conversation.
+ *
+ * @param string $mode next|prev|closest
+ *
+ * @return Conversation
+ */
+ public function getNearby($mode = 'closest', $folder_id = null, $status = null, $prev_if_no_next = false)
+ {
+ $conversation = null;
+
+ if ($folder_id) {
+ $folder = Folder::find($folder_id);
+ } else {
+ $folder = $this->folder;
+ }
+ //$query = self::where('folder_id', $folder->id)->where('id', '<>', $this->id);
+ $query = self::getQueryByFolder($folder, \Auth::id())
+ ->where('id', '<>', $this->id);
+
+ $query = \Eventy::filter('conversation.get_nearby_query', $query, $this, $mode, $folder);
+
+ if ($status) {
+ $query->where('status', $status);
+ }
+
+ $order_bys = $folder->getOrderByArray();
+
+ // Next.
+ if ($mode != 'prev') {
+ // Try to get next conversation
+ $query_next = clone $query;
+ foreach ($order_bys as $order_by) {
+ foreach ($order_by as $field => $sort_order) {
+ if (!$this->$field) {
+ continue;
+ }
+ $field_value = $this->$field;
+ if ($field == 'status' && $status !== null) {
+ $field_value = $status;
+ }
+ if ($sort_order == 'asc') {
+ $query_next->where($field, '>=', $field_value);
+ } else {
+ $query_next->where($field, '<=', $field_value);
+ }
+ $query_next->orderBy($field, $sort_order);
+ }
+ }
+ $conversation = $query_next->first();
+ }
+
+ // https://github.com/freescout-helpdesk/freescout/issues/3486
+ if ($conversation || ($mode == 'next' && !$prev_if_no_next)) {
+ return $conversation;
+ }
+
+ // Prev.
+ $query_prev = $query;
+ foreach ($order_bys as $order_by) {
+ foreach ($order_by as $field => $sort_order) {
+ if (!$this->$field) {
+ continue;
+ }
+ $field_value = $this->$field;
+ if ($field == 'status' && $status !== null) {
+ $field_value = $status;
+ }
+ if ($sort_order == 'asc') {
+ $query_prev->where($field, '<=', $field_value);
+ } else {
+ $query_prev->where($field, '>=', $field_value);
+ }
+ $query_prev->orderBy($field, $sort_order == 'asc' ? 'desc' : 'asc');
+ }
+ }
+
+ return $query_prev->first();
+ }
+
+ /**
+ * Get URL of the next conversation.
+ */
+ public function urlNext($folder_id = null, $status = null, $prev_if_no_next = false)
+ {
+ $next_conversation = $this->getNearby('next', $folder_id, $status, $prev_if_no_next);
+ if ($next_conversation) {
+ $url = $next_conversation->url();
+ } else {
+ // Show folder
+ $url = route('mailboxes.view.folder', ['id' => $this->mailbox_id, 'folder_id' => $this->getCurrentFolder($this->folder_id)]);
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get URL of the previous conversation.
+ */
+ public function urlPrev($folder_id = null)
+ {
+ $prev_conversation = $this->getNearby('prev', $folder_id);
+ if ($prev_conversation) {
+ $url = $prev_conversation->url();
+ } else {
+ // Show folder
+ $url = route('mailboxes.view.folder', ['id' => $this->mailbox_id, 'folder_id' => $this->getCurrentFolder($this->folder_id)]);
+ }
+
+ return $url;
+ }
+
+ /**
+ * Set folder according to the status, state and user of the conversation.
+ */
+ public function updateFolder($mailbox = null)
+ {
+ if ($this->state == self::STATE_DRAFT) {
+ $folder_type = Folder::TYPE_DRAFTS;
+ } elseif ($this->state == self::STATE_DELETED) {
+ $folder_type = Folder::TYPE_DELETED;
+ } elseif ($this->status == self::STATUS_SPAM) {
+ $folder_type = Folder::TYPE_SPAM;
+ } elseif ($this->status == self::STATUS_CLOSED) {
+ $folder_type = Folder::TYPE_CLOSED;
+ } elseif ($this->user_id) {
+ $folder_type = Folder::TYPE_ASSIGNED;
+ } else {
+ $folder_type = Folder::TYPE_UNASSIGNED;
+ }
+
+ if (!$mailbox) {
+ $mailbox = $this->mailbox;
+ }
+
+ // Find folder
+ $folder = $mailbox->folders()
+ ->where('type', $folder_type)
+ ->first();
+
+ if ($folder) {
+ $this->folder_id = $folder->id;
+ }
+ }
+
+ /**
+ * Set CC as JSON.
+ */
+ public function setCc($emails)
+ {
+ $emails_array = self::sanitizeEmails($emails);
+ if ($emails_array) {
+ $emails_array = array_unique($emails_array);
+ $this->cc = \Helper::jsonEncodeUtf8($emails_array);
+ } else {
+ $this->cc = null;
+ }
+ }
+
+ /**
+ * Set BCC as JSON.
+ */
+ public function setBcc($emails)
+ {
+ $emails_array = self::sanitizeEmails($emails);
+ if ($emails_array) {
+ $emails_array = array_unique($emails_array);
+ $this->bcc = \Helper::jsonEncodeUtf8($emails_array);
+ } else {
+ $this->bcc = null;
+ }
+ }
+
+ /**
+ * Get CC recipients.
+ *
+ * @return array
+ */
+ public function getCcArray($exclude_array = [])
+ {
+ return \App\Misc\Helper::jsonToArray($this->cc, $exclude_array);
+ }
+
+ /**
+ * Get BCC recipients.
+ *
+ * @return array
+ */
+ public function getBccArray($exclude_array = [])
+ {
+ return \App\Misc\Helper::jsonToArray($this->bcc, $exclude_array);
+ }
+
+ /**
+ * Convert list of email to array.
+ *
+ * @return
+ */
+ public static function sanitizeEmails($emails)
+ {
+ // Create customers if needed: Test '.$output.'
',
+ 'unescaped' => true,
+ 'type' => $type,
+ ];
+ \Cache::forever('modules_flash', $flash);
+ $response['status'] = 'success';
+ }
+
+ break;
+
+ case 'deactivate':
+ $alias = $request->alias;
+ \App\Module::setActive($alias, false);
+
+ $outputLog = new BufferedOutput();
+ \Artisan::call('freescout:clear-cache', [], $outputLog);
+ $output = $outputLog->fetch();
+
+ // Get module name
+ $module = \Module::findByAlias($alias);
+ $name = '?';
+ if ($module) {
+ $name = $module->getName();
+ }
+
+ $type = 'danger';
+ $msg = __('Error occurred deactivating :name module', ['name' => $name]);
+ if (strstr($output, 'Configuration cached successfully')) {
+ $type = 'success';
+ $msg = __('":name" module successfully Deactivated!', ['name' => $name]);
+ }
+
+ // \Session::flash does not work after BufferedOutput
+ $flash = [
+ 'text' => ''.$msg.''.$output.'
',
+ 'unescaped' => true,
+ 'type' => $type,
+ ];
+ \Cache::forever('modules_flash', $flash);
+ $response['status'] = 'success';
+ break;
+
+ case 'deactivate_license':
+ $license = $request->license;
+ $alias = $request->alias;
+
+ if (!$license) {
+ $response['msg'] = __('Empty license key');
+ }
+
+ if (!$response['msg']) {
+ $params = [
+ 'license' => $license,
+ 'module_alias' => $alias,
+ 'url' => (!empty($request->any_url) ? '*' : \App\Module::getAppUrl()),
+ ];
+ $result = WpApi::deactivateLicense($params);
+
+ if (WpApi::$lastError) {
+ $response['msg'] = WpApi::$lastError['message'];
+ } elseif (!empty($result['code']) && !empty($result['message'])) {
+ $response['msg'] = $result['message'];
+ } else {
+ if (!empty($result['status']) && $result['status'] == 'success') {
+ $db_module = \App\Module::getByAlias($alias);
+ if ($db_module && trim($db_module->license ?? '') == trim($license ?? '')) {
+ // Remove remembered license key and deactivate license in DB
+ \App\Module::deactivateLicense($alias, '');
+
+ // Deactivate module
+ \App\Module::setActive($alias, false);
+ \Artisan::call('freescout:clear-cache', []);
+ }
+
+ // Flash does not work here.
+ $flash = [
+ 'text' => ''.__('License successfully Deactivated!').'',
+ 'unescaped' => true,
+ 'type' => 'success',
+ ];
+ \Cache::forever('modules_flash', $flash);
+
+ $response['status'] = 'success';
+ } elseif (!empty($result['error'])) {
+ $response['msg'] = \App\Module::getErrorMessage($result['error'], $result);
+ } else {
+ $response['msg'] = __('Error occurred. Please try again later.');
+ }
+ }
+ }
+ break;
+
+ case 'delete':
+ $alias = $request->alias;
+
+ $module = \Module::findByAlias($alias);
+
+ if ($module) {
+
+ //\App\Module::deactivateLicense($alias, $license);
+
+ $module->delete();
+ \Session::flash('flash_success_floating', __('Module deleted'));
+ } else {
+ $response['msg'] = __('Module not found').': '.$alias;
+ }
+
+ $response['status'] = 'success';
+ break;
+
+ case 'update':
+ $update_result = \App\Module::updateModule($request->alias);
+
+ if ($update_result['download_error']) {
+ $response['reload'] = true;
+
+ if ($update_result['msg']) {
+ \Session::flash('flash_error_floating', $update_result['msg']);
+ }
+
+ if ($update_result['download_msg']) {
+ \Session::flash('flash_error_unescaped', $update_result['download_msg']);
+ }
+ }
+
+ // Install updated module.
+ if ($update_result['output'] || $update_result['status']) {
+
+ $type = 'danger';
+ $msg = $update_result['msg'];
+
+ if ($update_result['status'] == 'success') {
+ $type = 'success';
+ $msg = $update_result['msg_success'];
+ }
+
+ // \Session::flash does not work after BufferedOutput
+ $flash = [
+ 'text' => ''.$msg.''.$update_result['output'].'
',
+ 'unescaped' => true,
+ 'type' => $type,
+ ];
+ \Cache::forever('modules_flash', $flash);
+ $response['status'] = 'success';
+ }
+
+ break;
+
+ case 'update_all':
+ $update_all_flashes = [];
+
+ foreach ($request->aliases as $alias) {
+ $update_result = \App\Module::updateModule($alias);
+
+ $type = 'danger';
+ $msg = $update_result['msg'];
+
+ if ($update_result['status'] == 'success') {
+ $type = 'success';
+ $msg = $update_result['msg_success'];
+ } elseif ($update_result['download_msg']) {
+ $msg .= '
'.$update_result['download_msg'];
+ }
+
+ $text = ''.$update_result['module_name'].': '.$msg;
+ if (trim($update_result['output'])) {
+ $text .= ''.$update_result['output'].'
';
+ }
+
+ // \Session::flash does not work after BufferedOutput
+ $update_all_flashes[] = [
+ 'text' => $text,
+ 'unescaped' => true,
+ 'type' => $type,
+ ];
+ }
+ if ($update_all_flashes) {
+ \Cache::forever('modules_flash', $update_all_flashes);
+ }
+ $response['status'] = 'success';
+
+ break;
+
+ default:
+ $response['msg'] = 'Unknown action';
+ break;
+ }
+
+ if ($response['status'] == 'error' && empty($response['msg'])) {
+ $response['msg'] = 'Unknown error occurred';
+ }
+
+ return \Response::json($response);
+ }
+}
diff --git a/freescout-dist/app/Http/Controllers/OpenController.php b/freescout-dist/app/Http/Controllers/OpenController.php
new file mode 100644
index 0000000..706e5df
--- /dev/null
+++ b/freescout-dist/app/Http/Controllers/OpenController.php
@@ -0,0 +1,228 @@
+user()) {
+ return redirect()->route('dashboard');
+ }
+ $user = User::where('invite_hash', $hash)->first();
+
+ if ($user && $user->locale) {
+ \Helper::setLocale($user->locale);
+ }
+
+ return view('open/user_setup', ['user' => $user]);
+ }
+
+ /**
+ * Save user from invitation.
+ */
+ public function userSetupSave($hash, Request $request)
+ {
+ if (auth()->user()) {
+ return redirect()->route('dashboard');
+ }
+ $user = User::where('invite_hash', $hash)->first();
+
+ if (!$user) {
+ abort(404);
+ }
+
+ $validator = Validator::make($request->all(), [
+ 'email' => 'required|string|email|max:100|unique:users,email,'.$user->id,
+ 'password' => 'required|string|min:8|confirmed',
+ 'job_title' => 'max:100',
+ 'phone' => 'max:60',
+ 'timezone' => 'required|string|max:255',
+ 'time_format' => 'required',
+ 'photo_url' => 'nullable|image|mimes:jpeg,png,jpg,gif',
+ ]);
+ $validator->setAttributeNames([
+ 'photo_url' => __('Photo'),
+ ]);
+
+ // Photo
+ $validator->after(function ($validator) use ($user, $request) {
+ if ($request->hasFile('photo_url')) {
+ $path_url = $user->savePhoto($request->file('photo_url'));
+
+ if ($path_url) {
+ $user->photo_url = $path_url;
+ } else {
+ $validator->errors()->add('photo_url', __('Error occurred processing the image. Make sure that PHP GD extension is enabled.'));
+ }
+ }
+ });
+
+ if ($validator->fails()) {
+ return redirect()->route('user_setup', ['hash' => $hash])
+ ->withErrors($validator)
+ ->withInput();
+ }
+
+ $request_data = $request->all();
+ // Do not allow user to set his role
+ if (isset($request_data['role'])) {
+ unset($request_data['role']);
+ }
+ if (isset($request_data['photo_url'])) {
+ unset($request_data['photo_url']);
+ }
+ $user->fill($request_data);
+
+ $user->password = bcrypt($request->password);
+
+ $user->invite_state = User::INVITE_STATE_ACTIVATED;
+ $user->invite_hash = '';
+
+ $user = \Eventy::filter('user.setup_save', $user, $request);
+ $user->save();
+
+ // Login user
+ Auth::guard()->login($user);
+
+ \Session::flash('flash_success_floating', __('Welcome to :company_name!', ['company_name' => Option::getCompanyName()]));
+
+ return redirect()->route('dashboard');
+ }
+
+ /*
+ * Set a thread as read by customer
+ */
+ public function setThreadAsRead($conversation_id, $thread_id)
+ {
+ $conversation = Conversation::findOrFail($conversation_id);
+ $thread = Thread::findOrFail($thread_id);
+
+ // We only track the first opening
+ if (empty($thread->opened_at)) {
+ $thread->opened_at = date('Y-m-d H:i:s');
+ $thread->save();
+ \Eventy::action('thread.opened', $thread, $conversation);
+ }
+
+ // Create a 1x1 ttransparent pixel and return it
+ $pixel = sprintf('%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c%c', 71, 73, 70, 56, 57, 97, 1, 0, 1, 0, 128, 255, 0, 192, 192, 192, 0, 0, 0, 33, 249, 4, 1, 0, 0, 0, 0, 44, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 68, 1, 0, 59);
+ $response = \Response::make($pixel, 200);
+ $response->header('Content-type', 'image/gif');
+ $response->header('Content-Length', 42);
+ $response->header('Cache-Control', 'private, no-cache, no-cache=Set-Cookie, proxy-revalidate');
+ $response->header('Expires', 'Wed, 11 Jan 2000 12:59:00 GMT');
+ $response->header('Last-Modified', 'Wed, 11 Jan 2006 12:59:00 GMT');
+ $response->header('Pragma', 'no-cache');
+
+ return $response;
+ }
+
+ /**
+ * Download an attachment.
+ */
+ public function downloadAttachment($dir_1, $dir_2, $dir_3, $file_name, Request $request)
+ {
+ $id = $request->query('id', '');
+ $token = $request->query('token', '');
+ $attachment = null;
+
+ // Old attachments can not be requested by id.
+ if (!$token && $id) {
+ return \Helper::denyAccess();
+ }
+
+ // Get attachment by id.
+ if ($id) {
+ $attachment = Attachment::findOrFail($id);
+ }
+
+ if (!$attachment) {
+ $attachment = Attachment::where('file_dir', $dir_1.DIRECTORY_SEPARATOR.$dir_2.DIRECTORY_SEPARATOR.$dir_3.DIRECTORY_SEPARATOR)
+ ->where('file_name', $file_name)
+ ->firstOrFail();
+ }
+
+ // Only allow download if the attachment is public or if the token matches the hash of the contents
+ if ($token != $attachment->getToken() && (bool)$attachment->public !== true) {
+ return \Helper::denyAccess();
+ }
+
+ $view_attachment = false;
+ $file_ext = strtolower(pathinfo($attachment->file_name, PATHINFO_EXTENSION));
+
+ // Some file type should be viewed in the browser instead of downloading.
+ if (in_array($file_ext, config('app.viewable_attachments'))) {
+ $view_attachment = true;
+ }
+ // If HTML file is renamed into .txt for example it will be shown by the browser as HTML.
+ if ($view_attachment && $attachment->mime_type) {
+ $allowed_mime_type = false;
+
+ foreach (config('app.viewable_mime_types') as $mime_type) {
+ if (preg_match('#'.$mime_type.'#', $attachment->mime_type)) {
+ $allowed_mime_type = true;
+ break;
+ }
+ }
+ if (!$allowed_mime_type) {
+ $view_attachment = false;
+ }
+ }
+
+ if (config('app.download_attachments_via') == 'apache') {
+ // Send using Apache mod_xsendfile.
+ $response = response(null)
+ ->header('Content-Type' , $attachment->mime_type)
+ ->header('X-Sendfile', $attachment->getLocalFilePath());
+
+ if (!$view_attachment) {
+ $response->header('Content-Disposition', 'attachment; filename="'.$attachment->file_name.'"');
+ }
+ } elseif (config('app.download_attachments_via') == 'nginx') {
+ // Send using Nginx.
+ $response = response(null)
+ ->header('Content-Type' , $attachment->mime_type)
+ ->header('X-Accel-Redirect', $attachment->getLocalFilePath(false));
+
+ if (!$view_attachment) {
+ $response->header('Content-Disposition', 'attachment; filename="'.$attachment->file_name.'"');
+ }
+ } else {
+ $response = $attachment->download($view_attachment);
+ }
+
+ return $response;
+ }
+
+ /**
+ * Needed for the mobile app.
+ */
+ // public function mobilePing()
+ // {
+ // echo file_get_contents(public_path('installer/css/fontawesome.css'));
+ // }
+}
diff --git a/freescout-dist/app/Http/Controllers/SecureController.php b/freescout-dist/app/Http/Controllers/SecureController.php
new file mode 100644
index 0000000..793d55a
--- /dev/null
+++ b/freescout-dist/app/Http/Controllers/SecureController.php
@@ -0,0 +1,236 @@
+middleware('auth');
+ }
+
+ /**
+ * Show the application dashboard.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function dashboard()
+ {
+ $user = auth()->user();
+ if (!$user->isAdmin()) {
+ $mailboxes = $user->mailboxesCanView();
+ } else {
+ $mailboxes = $user->mailboxesCanViewWithSettings();
+ }
+
+ // Sort by name.
+ $mailboxes = \Eventy::filter('dashboard.mailboxes', $mailboxes->sortBy('name'));
+
+ return view('secure/dashboard', ['mailboxes' => $mailboxes]);
+ }
+
+ /**
+ * Logs.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function logs(Request $request)
+ {
+ function addCol($cols, $col)
+ {
+ if (!in_array($col, $cols)) {
+ $cols[] = $col;
+ }
+
+ return $cols;
+ }
+
+ // No need to check permissions here, as they are checked in routing
+
+ $names = ActivityLog::select('log_name')->distinct()->pluck('log_name')->toArray();
+
+ $activities = [];
+ $cols = [];
+ $page_size = 20;
+ $name = '';
+
+ if (!empty($request->name)) {
+ $activities = ActivityLog::inLog($request->name)->orderBy('created_at', 'desc')->paginate($page_size);
+ $name = $request->name;
+ } elseif (count($names)) {
+ $name = ActivityLog::NAME_OUT_EMAILS;
+ // $activities = ActivityLog::inLog($names[0])->orderBy('created_at', 'desc')->paginate($page_size);
+ // $name = $names[0];
+ }
+
+ if ($name != ActivityLog::NAME_OUT_EMAILS) {
+ $logs = [];
+ $cols = ['date'];
+ foreach ($activities as $activity) {
+ $log = [];
+ $log['date'] = $activity->created_at;
+ if ($activity->causer) {
+ if ($activity->causer_type == 'App\User') {
+ $cols = addCol($cols, 'user');
+ $log['user'] = $activity->causer;
+ } else {
+ $cols = addCol($cols, 'customer');
+ $log['customer'] = $activity->causer;
+ }
+ }
+ $log['event'] = $activity->getEventDescription();
+
+ $cols = addCol($cols, 'event');
+
+ foreach ($activity->properties as $property_name => $property_value) {
+ if (!is_string($property_value)) {
+ $property_value = json_encode($property_value);
+ }
+ $log[$property_name] = $property_value;
+ $cols = addCol($cols, $property_name);
+ }
+
+ $logs[] = $log;
+ }
+ } else {
+ // Outgoing emails are displayed from send log
+ $logs = [];
+ $cols = [
+ 'date',
+ 'type',
+ 'email',
+ 'status',
+ 'message',
+ 'user',
+ 'customer',
+ ];
+
+ $activities_query = SendLog::orderBy('created_at', 'desc');
+ if ($request->get('thread_id')) {
+ $activities_query->where('thread_id', $request->get('thread_id'));
+ }
+ $activities = $activities_query->paginate($page_size);
+
+ foreach ($activities as $record) {
+ $conversation = '';
+ if ($record->thread_id) {
+ $conversation = Thread::find($record->thread_id);
+ }
+
+ $status = $record->getStatusName();
+ if ($record->status_message) {
+ $status .= '. '.$record->status_message;
+ if ($record->status == SendLog::STATUS_SEND_ERROR) {
+ $status .= '. Message-ID: '.$record->message_id;
+ }
+ }
+ if ($record->smtp_queue_id) {
+ $status .= '. SMTP ID: '.$record->smtp_queue_id;
+ }
+
+ $logs[] = [
+ 'date' => $record->created_at,
+ 'type' => $record->getMailTypeName(),
+ 'email' => $record->email,
+ 'status' => $status,
+ 'message' => $conversation,
+ 'user' => $record->user,
+ 'customer' => $record->customer,
+ ];
+ }
+ }
+
+ array_unshift($names, ActivityLog::NAME_OUT_EMAILS);
+ array_push($names, ActivityLog::NAME_APP_LOGS);
+
+ if (!in_array($name, $names)) {
+ $names[] = $name;
+ }
+
+ return view('secure/logs', [
+ 'logs' => $logs,
+ 'names' => $names,
+ 'current_name' => $name,
+ 'cols' => $cols,
+ 'activities' => $activities,
+ ]);
+ }
+
+ /**
+ * Logs page submitted.
+ */
+ public function logsSubmit(Request $request)
+ {
+ // No need to check permissions here, as they are checked in routing
+
+ $name = '';
+ if (!empty($request->name)) {
+ //$activities = ActivityLog::inLog($request->name)->orderBy('created_at', 'desc')->get();
+ $name = $request->name;
+ } elseif (count($names = ActivityLog::select('log_name')->distinct()->get()->pluck('log_name'))) {
+ $name = ActivityLog::NAME_OUT_EMAILS;
+ // $activities = ActivityLog::inLog($names[0])->orderBy('created_at', 'desc')->get();
+ // $name = $names[0];
+ }
+
+ switch ($request->action) {
+ case 'clean':
+ if ($name && $name != ActivityLog::NAME_OUT_EMAILS) {
+ ActivityLog::where('log_name', $name)->delete();
+ \Session::flash('flash_success_floating', __('Log successfully cleared'));
+ }
+ break;
+ }
+
+ return redirect()->route('logs', ['name' => $name]);
+ }
+
+ /**
+ * Upload files and images.
+ */
+ public function upload(Request $request, $allowed_exts = [])
+ {
+ // 'jpg','gif','png'
+ $response = [
+ 'status' => 'error',
+ 'msg' => '', // this is error message
+ ];
+
+ $user = auth()->user();
+
+ if (!$user) {
+ $response['msg'] = __('Please login to upload file');
+ }
+
+ if (!$request->hasFile('file') || !$request->file('file')->isValid() || !$request->file) {
+ $response['msg'] = __('Error occurred uploading file');
+ }
+
+ if (!$response['msg']) {
+
+ $upload = Helper::uploadFile($request->file, $allowed_exts);
+ $filename = basename($upload);
+
+ if ($upload) {
+ $response['status'] = 'success';
+ $response['url'] = Helper::uploadedFileUrl($filename);
+ } else {
+ $response['msg'] = __('Error occurred uploading file');
+ }
+ }
+
+ return \Response::json($response);
+ }
+}
diff --git a/freescout-dist/app/Http/Controllers/SettingsController.php b/freescout-dist/app/Http/Controllers/SettingsController.php
new file mode 100644
index 0000000..2ebcb22
--- /dev/null
+++ b/freescout-dist/app/Http/Controllers/SettingsController.php
@@ -0,0 +1,431 @@
+middleware('auth');
+ }
+
+ /**
+ * General settings.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function view($section = 'general')
+ {
+ $settings = $this->getSectionSettings($section);
+
+ if (!$settings) {
+ abort(404);
+ }
+
+ $sections = $this->getSections();
+
+ $template_vars = [
+ 'settings' => $settings,
+ 'section' => $section,
+ 'sections' => $this->getSections(),
+ 'section_name' => $sections[$section]['title'],
+ ];
+ $template_vars = $this->getTemplateVars($section, $template_vars);
+
+ return view('settings/view', $template_vars);
+ }
+
+ public function getValidator($section)
+ {
+ $rules = $this->getSectionParams($section, 'validator_rules');
+
+ if (!empty($rules)) {
+ return Validator::make(request()->all(), $rules);
+ }
+ }
+
+ public function getTemplateVars($section, $template_vars)
+ {
+ $section_vars = $this->getSectionParams($section, 'template_vars');
+
+ if ($section_vars && is_array($section_vars)) {
+ return array_merge($template_vars, $section_vars);
+ } else {
+ return $template_vars;
+ }
+ }
+
+ /**
+ * Parameters of the sections settings.
+ *
+ * If in settings parameter `env` is set, option will be saved into .env file
+ * instead of DB.
+ *
+ * @param [type] $section [description]
+ * @param string $param [description]
+ *
+ * @return [type] [description]
+ */
+ public function getSectionParams($section, $param = '')
+ {
+ $params = [];
+
+ switch ($section) {
+ case 'emails':
+ $params = [
+ 'template_vars' => [
+ 'sendmail_path' => ini_get('sendmail_path'),
+ 'mail_drivers' => [
+ 'mail' => __("PHP's mail() function"),
+ 'sendmail' => __('Sendmail'),
+ 'smtp' => 'SMTP',
+ ],
+ ],
+ 'validator_rules' => [
+ 'settings.mail_from' => 'required|email',
+ ],
+ 'settings' => [
+ 'fetch_schedule' => [
+ 'env' => 'APP_FETCH_SCHEDULE',
+ ],
+ 'mail_password' => [
+ 'safe_password' => true,
+ 'encrypt' => true,
+ ],
+ // 'use_mail_date_on_fetching' => [
+ // 'env' => 'APP_USE_MAIL_DATE_ON_FETCHING',
+ // ],
+ ],
+ ];
+ break;
+ case 'general':
+ $params = [
+ 'settings' => [
+ 'custom_number' => [
+ 'env' => 'APP_CUSTOM_NUMBER',
+ ],
+ 'max_message_size' => [
+ 'env' => 'APP_MAX_MESSAGE_SIZE',
+ ],
+ 'email_conv_history' => [
+ 'env' => 'APP_EMAIL_CONV_HISTORY',
+ ],
+ 'email_user_history' => [
+ 'env' => 'APP_EMAIL_USER_HISTORY',
+ ],
+ 'locale' => [
+ 'env' => 'APP_LOCALE',
+ ],
+ 'timezone' => [
+ 'env' => 'APP_TIMEZONE',
+ ],
+ 'user_permissions' => [
+ 'env' => 'APP_USER_PERMISSIONS',
+ 'env_encode' => true,
+ ],
+ ],
+ ];
+ break;
+ case 'alerts':
+ $subscriptions_defaults = Subscription::getDefaultSubscriptions();
+ $subscriptions = array();
+ foreach ($subscriptions_defaults as $medium => $subscriptions_for_medium) {
+ foreach ($subscriptions_defaults[$medium] as $subscription) {
+ $subscriptions[] = (object) array("medium" => $medium, "event" => $subscription);
+ }
+ }
+ $params = [
+ 'template_vars' => [
+ 'logs' => \App\ActivityLog::getAvailableLogs(),
+ 'person' => null,
+ 'subscriptions' => $subscriptions,
+ 'mobile_available' => \Eventy::filter('notifications.mobile_available', false),
+ ],
+ 'settings' => [
+ 'alert_logs' => [
+ 'env' => 'APP_ALERT_LOGS',
+ ],
+ 'alert_logs_period' => [
+ 'env' => 'APP_ALERT_LOGS_PERIOD',
+ ],
+ ],
+ ];
+
+ // todo: monitor App Logs
+ foreach ($params['template_vars']['logs'] as $i => $log) {
+ if ($log == \App\ActivityLog::NAME_APP_LOGS || $log == \App\ActivityLog::NAME_OUT_EMAILS) {
+ unset($params['template_vars']['logs'][$i]);
+ }
+ }
+
+ break;
+ default:
+ $params = \Eventy::filter('settings.section_params', $params, $section);
+ break;
+ }
+
+ $params = \Eventy::filter('settings.alter_section_params', $params, $section);
+
+ if ($param) {
+ if (isset($params[$param])) {
+ return $params[$param];
+ } else {
+ return;
+ }
+ } else {
+ return $params;
+ }
+ }
+
+ public function getSectionSettings($section)
+ {
+ $settings = [];
+
+ switch ($section) {
+ case 'general':
+ $settings = [
+ 'company_name' => Option::get('company_name', \Config::get('app.name')),
+ 'next_ticket' => (Option::get('next_ticket') >= Conversation::max('number') + 1) ? Option::get('next_ticket') : Conversation::max('number') + 1,
+ 'custom_number' => (int)config('app.custom_number'),
+ 'user_permissions' => User::getGlobalUserPermissions(),
+ 'email_branding' => Option::get('email_branding'),
+ 'open_tracking' => Option::get('open_tracking'),
+ 'email_conv_history' => config('app.email_conv_history'),
+ 'max_message_size' => config('app.max_message_size'),
+ 'email_user_history' => config('app.email_user_history'),
+ 'enrich_customer_data' => Option::get('enrich_customer_data'),
+ 'time_format' => Option::get('time_format', User::TIME_FORMAT_24),
+ 'locale' => \Helper::getRealAppLocale(),
+ 'timezone' => config('app.timezone'),
+ ];
+ break;
+ case 'emails':
+ $settings = [
+ 'mail_from' => \App\Misc\Mail::getSystemMailFrom(),
+ 'mail_driver' => Option::get('mail_driver', \Config::get('mail.driver')),
+ 'mail_host' => Option::get('mail_host', \Config::get('mail.host')),
+ 'mail_port' => Option::get('mail_port', \Config::get('mail.port')),
+ 'mail_username' => Option::get('mail_username', \Config::get('mail.username')),
+ 'mail_password' => \Helper::decrypt(Option::get('mail_password', \Config::get('mail.password'))),
+ 'mail_encryption' => Option::get('mail_encryption', \Config::get('mail.encryption')),
+ 'fetch_schedule' => config('app.fetch_schedule'),
+ //'use_mail_date_on_fetching' => config('app.use_mail_date_on_fetching'),
+ ];
+ break;
+ case 'alerts':
+ $settings = Option::getOptions([
+ 'alert_recipients',
+ 'alert_fetch',
+ 'alert_fetch_period',
+ 'alert_logs',
+ 'alert_logs_names',
+ 'alert_logs_period',
+ 'subscription_defaults',
+ ], [
+ 'alert_logs_names' => [],
+ 'alert_logs' => config('app.alert_logs'),
+ 'alert_logs_period' => config('app.alert_logs_period'),
+ ]);
+ break;
+ default:
+ $settings = \Eventy::filter('settings.section_settings', $settings, $section);
+ break;
+ }
+
+ $settings = \Eventy::filter('settings.alter_section_settings', $settings, $section);
+
+ return $settings;
+ }
+
+ public function getSections()
+ {
+ $sections = [
+ // todo: order
+ 'general' => ['title' => __('General'), 'icon' => 'cog', 'order' => 100],
+ 'emails' => ['title' => __('Mail Settings'), 'icon' => 'transfer', 'order' => 200],
+ 'alerts' => ['title' => __('Alerts'), 'icon' => 'bell', 'order' => 300],
+ ];
+ $sections = \Eventy::filter('settings.sections', $sections);
+
+ return $sections;
+ }
+
+ /**
+ * Save general settings.
+ *
+ * @param \Illuminate\Http\Request $request
+ */
+ public function save($section = 'general')
+ {
+ $settings = $this->getSectionSettings($section);
+
+ if (!$settings) {
+ abort(404);
+ }
+
+ return $this->processSave($section, array_keys($settings));
+ }
+
+ public function processSave($section, $settings)
+ {
+ // Validate
+ $validator = $this->getValidator($section);
+
+ if ($validator && $validator->fails()) {
+ return redirect()->route('settings', ['section' => $section])
+ ->withErrors($validator)
+ ->withInput();
+ }
+
+ $request = request();
+
+ $request = \Eventy::filter('settings.before_save', $request, $section, $settings);
+
+ $cc_required = false;
+ $settings_params = $this->getSectionParams($section, 'settings');
+ foreach ($settings as $i => $option_name) {
+ // Do not save dummy passwords.
+ if (!empty($settings_params[$option_name])
+ && !empty($settings_params[$option_name]['safe_password'])
+ && $request->settings[$option_name]
+ && preg_match("/^\*+$/", $request->settings[$option_name])
+ ) {
+ continue;
+ }
+
+ // Option has to be saved to .env file.
+ if (!empty($settings_params[$option_name]) && !empty($settings_params[$option_name]['env'])) {
+ $env_value = $request->settings[$option_name] ?? '';
+
+ if (is_array($env_value)) {
+ $env_value = json_encode($env_value);
+ }
+
+ if (!empty($settings_params[$option_name]['encrypt'])) {
+ $env_value = encrypt($env_value);
+ }
+
+ if (!empty($settings_params[$option_name]['env_encode'])) {
+ $env_value = base64_encode($env_value);
+ }
+
+ \Helper::setEnvFileVar($settings_params[$option_name]['env'], $env_value);
+
+ config($option_name, $env_value);
+ $cc_required = true;
+ continue;
+ }
+
+ // By some reason isset() does not work for empty elements.
+ if (isset($request->settings) && array_key_exists($option_name, $request->settings)) {
+ $option_value = $request->settings[$option_name];
+
+ if (!empty($settings_params[$option_name]['encrypt'])) {
+ $option_value = encrypt($option_value);
+ }
+
+ Option::set($option_name, $option_value);
+ } else {
+ // If option does not exist, default will be used,
+ // so we can not just remove bool settings.
+ if (isset($settings_params[$option_name]['default'])) {
+ $default = $settings_params[$option_name]['default'];
+ } else {
+ $default = \Option::getDefault($option_name, null);
+ }
+ if ($default === true) {
+ Option::set($option_name, false);
+ } elseif (is_array(\Option::getDefault($option_name, -1))) {
+ Option::set($option_name, []);
+ } else {
+ Option::remove($option_name);
+ }
+ }
+ }
+
+ // Clear cache if some options have been saved to .env file.
+ // Clearing the cache also restarts queue:work as it also
+ // needs to get new .env parameters.
+ if ($cc_required) {
+ \Helper::clearCache(['--doNotGenerateVars' => true]);
+ }
+
+ // \Helper::clearCache prevents \Session::flash() from displaying.
+ $request->session()->flash('flash_success_floating', __('Settings updated'));
+
+ $response = redirect()->route('settings', ['section' => $section]);
+
+ $response = \Eventy::filter('settings.after_save', $response, $request, $section, $settings);
+
+ return $response;
+ }
+
+ /**
+ * Users ajax controller.
+ */
+ public function ajax(Request $request)
+ {
+ $response = [
+ 'status' => 'error',
+ 'msg' => '', // this is error message
+ ];
+
+ $user = auth()->user();
+
+ switch ($request->action) {
+
+ // Test sending emails from mailbox
+ case 'send_test':
+
+ if (empty($request->to)) {
+ $response['msg'] = __('Please specify recipient of the test email');
+ }
+
+ if (!$response['msg']) {
+ $test_result = false;
+
+ try {
+ $test_result = \MailHelper::sendTestMail($request->to);
+ } catch (\Exception $e) {
+ $response['msg'] = $e->getMessage();
+ }
+
+ if (!$test_result && !$response['msg']) {
+ $response['msg'] = __('Error occurred sending email. Please check your mail server logs for more details.');
+ }
+ }
+
+ if (!$response['msg']) {
+ $response['status'] = 'success';
+ }
+
+ // Remember email address
+ if (!empty($request->to)) {
+ \App\Option::set('send_test_to', $request->to);
+ }
+ break;
+
+ default:
+ $response['msg'] = 'Unknown action';
+ break;
+ }
+
+ if ($response['status'] == 'error' && empty($response['msg'])) {
+ $response['msg'] = 'Unknown error occurred';
+ }
+
+ return \Response::json($response);
+ }
+}
diff --git a/freescout-dist/app/Http/Controllers/SystemController.php b/freescout-dist/app/Http/Controllers/SystemController.php
new file mode 100644
index 0000000..fd3f2b9
--- /dev/null
+++ b/freescout-dist/app/Http/Controllers/SystemController.php
@@ -0,0 +1,416 @@
+middleware('auth', ['except' => [
+ 'cron'
+ ]]);
+ }
+
+ /**
+ * System status.
+ */
+ public function status(Request $request)
+ {
+ // PHP extensions.
+ $php_extensions = \Helper::checkRequiredExtensions();
+
+ // Functions.
+ $functions = \Helper::checkRequiredFunctions();
+
+ // Permissions.
+ $permissions = [];
+ foreach (config('installer.permissions') as $perm_path => $perm_value) {
+ $path = base_path($perm_path);
+ $value = '';
+ if (file_exists($path)) {
+ $value = substr(sprintf('%o', fileperms($path)), -4);
+ }
+ $permissions[$perm_path] = [
+ 'status' => \Helper::isFolderWritable($path),
+ 'value' => $value,
+ ];
+ }
+
+ // Check if cache files are writable.
+ $non_writable_cache_file = '';
+ if (function_exists('shell_exec')) {
+ $non_writable_cache_file = shell_exec('find '.base_path('storage/framework/cache/data/').' -type f | xargs -I {} sh -c \'[ ! -w "{}" ] && echo {}\' 2>&1 | head -n 1');
+ $non_writable_cache_file = trim($non_writable_cache_file ?? '');
+ // Leave only one line (in case head -n 1 does not work)
+ $non_writable_cache_file = preg_replace("#[\r\n].+#m", '', $non_writable_cache_file);
+ if (!strstr($non_writable_cache_file, base_path('storage/framework/cache/data/'))) {
+ $non_writable_cache_file = '';
+ }
+ }
+
+
+ // Check if public symlink exists, if not, try to create.
+ $public_symlink_exists = true;
+ $public_path = public_path('storage');
+ $public_test = $public_path.DIRECTORY_SEPARATOR.'.gitignore';
+
+ if (!file_exists($public_test) || !file_get_contents($public_test)) {
+ \File::delete($public_path);
+ \Artisan::call('storage:link');
+ if (!file_exists($public_test) || !file_get_contents($public_test)) {
+ $public_symlink_exists = false;
+ }
+ }
+
+ // Check if .env is writable.
+ $env_is_writable = is_writable(base_path('.env'));
+
+ // Jobs
+ $queued_jobs = \App\Job::orderBy('created_at', 'desc')->get();
+ $failed_jobs = \App\FailedJob::orderBy('failed_at', 'desc')->get();
+ $failed_queues = $failed_jobs->pluck('queue')->unique();
+
+ // Commands
+ $commands_list = [
+ 'freescout:fetch-emails' => 'freescout:fetch-emails',
+ \Helper::getWorkerIdentifier() => 'queue:work'
+ ];
+ foreach ($commands_list as $command_identifier => $command_name) {
+ $status_texts = [];
+
+ // Check if command is running now
+ if (function_exists('shell_exec')) {
+ $running_commands = 0;
+
+ try {
+ $processes = preg_split("/[\r\n]/", shell_exec("ps aux | grep '{$command_identifier}'"));
+ $pids = [];
+ foreach ($processes as $process) {
+ $process = trim($process);
+ preg_match("/^[\S]+\s+([\d]+)\s+/", $process, $m);
+ if (empty($m)) {
+ // Another format (used in Docker image).
+ // 1713 nginx 0:00 /usr/bin/php82...
+ preg_match("/^([\d]+)\s+[\S]+\s+/", $process, $m);
+ }
+ if (!preg_match("/(sh \-c|grep )/", $process) && !empty($m[1])) {
+ $running_commands++;
+ $pids[] = $m[1];
+ }
+ }
+ } catch (\Exception $e) {
+ // Do nothing
+ }
+ if ($running_commands == 1) {
+ $commands[] = [
+ 'name' => $command_name,
+ 'status' => 'success',
+ 'status_text' => __('Running'),
+ ];
+ continue;
+ } elseif ($running_commands > 1) {
+ // queue:work command is stopped by settings a cache key
+ if ($command_name == 'queue:work') {
+ \Helper::queueWorkerRestart();
+ $commands[] = [
+ 'name' => $command_name,
+ 'status' => 'error',
+ 'status_text' => __(':number commands were running at the same time. Commands have been restarted', ['number' => $running_commands]),
+ ];
+ } else {
+ unset($pids[0]);
+ $commands[] = [
+ 'name' => $command_name,
+ 'status' => 'error',
+ 'status_text' => __(':number commands are running at the same time. Please stop extra commands by executing the following console command:', ['number' => $running_commands]).' kill '.implode(' | kill ', $pids),
+ ];
+ }
+ continue;
+ }
+ }
+ // Check last run
+ $option_name = str_replace('freescout_', '', preg_replace('/[^a-zA-Z0-9]/', '_', $command_name));
+
+ $date_text = '?';
+ $last_run = Option::get($option_name.'_last_run');
+ if ($last_run) {
+ $date = Carbon::createFromTimestamp($last_run);
+ $date_text = User::dateFormat($date);
+ }
+ $status_texts[] = __('Last run:').' '.$date_text;
+
+ $date_text = '?';
+ $last_successful_run = Option::get($option_name.'_last_successful_run');
+ if ($last_successful_run) {
+ $date_ = Carbon::createFromTimestamp($last_successful_run);
+ $date_text = User::dateFormat($date);
+ }
+ $status_texts[] = __('Last successful run:').' '.$date_text;
+
+ $status = 'error';
+ if ($last_successful_run && $last_run && (int) $last_successful_run >= (int) $last_run) {
+ unset($status_texts[0]);
+ $status = 'success';
+ }
+
+ // If queue:work is not running, clear cache to let it start if something is wrong with the mutex
+ if ($command_name == 'queue:work' && !$last_successful_run) {
+ $status_texts[] = __('Try to :%a_start%clear cache:%a_end% to force command to start.', ['%a_start%' => '', '%a_end%' => '']);
+ // This sometimes makes Status page open as non logged in user.
+ //\Artisan::call('freescout:clear-cache', ['--doNotGenerateVars' => true]);
+ }
+
+ $commands[] = [
+ 'name' => $command_name,
+ 'status' => $status,
+ 'status_text' => implode(' ', $status_texts),
+ ];
+ }
+
+ // Check new version if enabled
+ $new_version_available = false;
+ if (!\Config::get('app.disable_updating')) {
+ $latest_version = \Cache::remember('latest_version', 15, function () {
+ try {
+ return \Updater::getVersionAvailable();
+ } catch (\Exception $e) {
+ SystemController::$latest_version_error = $e->getMessage();
+ return '';
+ }
+ });
+
+ if ($latest_version && version_compare($latest_version, \Config::get('app.version'), '>')) {
+ $new_version_available = true;
+ }
+ } else {
+ $latest_version = \Config::get('app.version');
+ }
+
+ // Detect missing migrations.
+ $migrations_output = \Helper::runCommand('migrate:status');
+ preg_match_all("#\| N \| ([^\|]+)\|#", $migrations_output, $migrations_m);
+ $missing_migrations = $migrations_m[1] ?? [];
+
+ return view('system/status', [
+ 'commands' => $commands,
+ 'queued_jobs' => $queued_jobs,
+ 'failed_jobs' => $failed_jobs,
+ 'failed_queues' => $failed_queues,
+ 'php_extensions' => $php_extensions,
+ 'functions' => $functions,
+ 'permissions' => $permissions,
+ 'new_version_available' => $new_version_available,
+ 'latest_version' => $latest_version,
+ 'latest_version_error' => SystemController::$latest_version_error,
+ 'public_symlink_exists' => $public_symlink_exists,
+ 'env_is_writable' => $env_is_writable,
+ 'non_writable_cache_file' => $non_writable_cache_file,
+ 'missing_migrations' => $missing_migrations,
+ 'invalid_symlinks' => \App\Module::checkSymlinks(),
+ ]);
+ }
+
+ public function action(Request $request)
+ {
+ switch ($request->action) {
+ case 'cancel_job':
+ \App\Job::where('id', $request->job_id)->delete();
+ \Session::flash('flash_success_floating', __('Done'));
+ break;
+
+ case 'retry_job':
+ \App\Job::where('id', $request->job_id)->update(['available_at' => time()]);
+ sleep(1);
+ \Session::flash('flash_success_floating', __('Done'));
+ break;
+
+ case 'delete_failed_jobs':
+ \App\FailedJob::where('queue', $request->failed_queue)->delete();
+ \Session::flash('flash_success_floating', __('Failed jobs deleted'));
+ break;
+
+ case 'retry_failed_jobs':
+ $jobs = \App\FailedJob::where('queue', $request->failed_queue)->get();
+ foreach ($jobs as $job) {
+ \Artisan::call('queue:retry', ['id' => $job->id]);
+ }
+ \Session::flash('flash_success_floating', __('Failed jobs restarted'));
+ break;
+ }
+
+ return redirect()->route('system');
+ }
+
+ /**
+ * System tools.
+ */
+ public function tools(Request $request)
+ {
+ $output = \Cache::get('tools_execute_output');
+ if ($output) {
+ \Cache::forget('tools_execute_output');
+ }
+
+ return view('system/tools', [
+ 'output' => $output,
+ ]);
+ }
+
+ /**
+ * Execute tools action.
+ *
+ * @param Request $request [description]
+ *
+ * @return [type] [description]
+ */
+ public function toolsExecute(Request $request)
+ {
+ $outputLog = new BufferedOutput();
+
+ switch ($request->action) {
+ case 'clear_cache':
+ \Artisan::call('freescout:clear-cache', [], $outputLog);
+ break;
+
+ case 'fetch_emails':
+ $params = [];
+ $params['--days'] = (int)$request->days;
+ $params['--unseen'] = (int)$request->unseen;
+ \Artisan::call('freescout:fetch-emails', $params, $outputLog);
+ break;
+
+ case 'migrate_db':
+ \Artisan::call('migrate', ['--force' => true], $outputLog);
+ break;
+
+ case 'logout_users':
+ \Artisan::call('freescout:logout-users', [], $outputLog);
+ break;
+ }
+
+ $output = $outputLog->fetch();
+ unset($outputLog);
+
+ if ($output) {
+ // \Session::flash does not work after BufferedOutput
+ \Cache::forever('tools_execute_output', $output);
+ }
+
+ return redirect()->route('system.tools')->withInput($request->input());
+ }
+
+ /**
+ * Ajax.
+ */
+ public function ajax(Request $request)
+ {
+ $response = [
+ 'status' => 'error',
+ 'msg' => '', // this is error message
+ ];
+
+ switch ($request->action) {
+
+ case 'update':
+ try {
+ $status = \Updater::update();
+
+ // Artisan::output()
+ } catch (\Exception $e) {
+ $response['msg'] = __('Error occurred. Please try again or try another :%a_start%update method:%a_end%', ['%a_start%' => '', '%a_end%' => '']);
+ $response['msg'] .= '
'.$e->getMessage();
+
+ \Helper::logException($e);
+ }
+ if (!$response['msg'] && $status) {
+ // Adding session flash is useless as cache is cleared
+ $response['msg_success'] = __('Application successfully updated');
+ $response['status'] = 'success';
+ }
+ break;
+
+ case 'check_updates':
+ if (!\Config::get('app.disable_updating')) {
+ try {
+ $response['new_version_available'] = \Updater::isNewVersionAvailable(config('app.version'));
+ $response['status'] = 'success';
+ } catch (\Exception $e) {
+ $response['msg'] = __('Error occurred').': '.$e->getMessage();
+ }
+ if (!$response['msg'] && !$response['new_version_available']) {
+ // Adding session flash is useless as cache is cleated
+ $response['msg_success'] = __('You have the latest version installed');
+ }
+ } else {
+ $response['msg_success'] = __('You have the latest version installed');
+ }
+ break;
+
+ default:
+ $response['msg'] = 'Unknown action';
+ break;
+ }
+
+ if ($response['status'] == 'error' && empty($response['msg'])) {
+ $response['msg'] = 'Unknown error occurred';
+ }
+
+ return \Response::json($response);
+ }
+
+ /**
+ * Web Cron.
+ */
+ public function cron(Request $request)
+ {
+ if (empty($request->hash) || $request->hash != \Helper::getWebCronHash()) {
+ abort(404);
+ }
+ $outputLog = new BufferedOutput();
+ \Artisan::call('schedule:run', [], $outputLog);
+ $output = $outputLog->fetch();
+
+ return response($output, 200)->header('Content-Type', 'text/plain');
+ }
+
+ /**
+ * Ajax HTML.
+ */
+ public function ajaxHtml(Request $request)
+ {
+ switch ($request->action) {
+ case 'job_details':
+ $job = \App\FailedJob::find($request->param);
+ if (!$job) {
+ abort(404);
+ }
+
+ $html = '';
+ $payload = json_decode($job->payload, true);
+
+ if (!empty($payload['data']['command'])) {
+ $html .= ''.print_r(unserialize($payload['data']['command']), 1).'
';
+ }
+
+ $html .= ''.$job->exception.'
';
+
+ return response($html);
+ }
+
+ abort(404);
+ }
+}
diff --git a/freescout-dist/app/Http/Controllers/TranslateController.php b/freescout-dist/app/Http/Controllers/TranslateController.php
new file mode 100644
index 0000000..8756de2
--- /dev/null
+++ b/freescout-dist/app/Http/Controllers/TranslateController.php
@@ -0,0 +1,94 @@
+where('status', Translation::STATUS_CHANGED)
+ ->groupBy(['locale', 'group'])
+ ->get()
+ ->toArray();
+
+ $this->manager->exportTranslations('*', false);
+
+ // Archive langs folder
+ try {
+ $archive_path = \Helper::createZipArchive(base_path().DIRECTORY_SEPARATOR.'resources/lang', 'lang.zip', 'lang');
+ } catch (\Exception $e) {
+ return [
+ 'status' => 'error',
+ 'error_msg' => $e->getMessage(),
+ ];
+ }
+
+ if ($archive_path) {
+ $attachments[] = $archive_path;
+
+ // Send archive to developers
+ $result = \MailHelper::sendEmailToDevs('Translations', json_encode($changed_data), $attachments, auth()->user());
+ }
+
+ if ($result) {
+ return ['status' => 'ok'];
+ } else {
+ abort(500);
+ }
+ }
+
+ /**
+ * Remove all translations which has not been published yet.
+ *
+ * @return [type] [description]
+ */
+ public function postRemoveUnpublished()
+ {
+ \Barryvdh\TranslationManager\Models\Translation::truncate();
+
+ return ['status' => 'ok'];
+ }
+
+ /**
+ * Download as ZIP.
+ *
+ * @return [type] [description]
+ */
+ public function postDownload()
+ {
+ $this->manager->exportTranslations('*', false);
+ $file_name = 'lang.zip';
+ // Archive langs folder
+ $archive_path = \Helper::createZipArchive(base_path().DIRECTORY_SEPARATOR.'resources/lang', $file_name, 'lang');
+ $public_path = storage_path('app/public/'.$file_name);
+
+ \File::copy($archive_path, $public_path);
+
+ $headers = [
+ 'Content-Type: application/zip',
+ ];
+
+ return \Response::download($public_path, $file_name, $headers);
+ }
+
+ /**
+ * List of strings to translate.
+ */
+ public function stringsToTranslate()
+ {
+ __(':field is required');
+ __('The following modules have to be installed and activated: :modules');
+ }
+}
diff --git a/freescout-dist/app/Http/Controllers/UsersController.php b/freescout-dist/app/Http/Controllers/UsersController.php
new file mode 100644
index 0000000..8fb4810
--- /dev/null
+++ b/freescout-dist/app/Http/Controllers/UsersController.php
@@ -0,0 +1,660 @@
+middleware('auth');
+ }
+
+ /**
+ * Users list.
+ */
+ public function users()
+ {
+ $this->authorize('create', 'App\User');
+
+ $users = User::nonDeleted()->get();
+ $users = User::sortUsers($users);
+
+ return view('users/users', ['users' => $users]);
+ }
+
+ /**
+ * New user.
+ */
+ public function create()
+ {
+ $this->authorize('create', 'App\User');
+ $mailboxes = Mailbox::all();
+
+ return view('users/create', ['mailboxes' => $mailboxes]);
+ }
+
+ /**
+ * Create new user.
+ *
+ * @param \Illuminate\Http\Request $request
+ */
+ public function createSave(Request $request)
+ {
+ $invalid = false;
+ $this->authorize('create', 'App\User');
+ $auth_user = auth()->user();
+
+ $rules = [
+ 'first_name' => 'required|string|max:20',
+ 'last_name' => 'required|string|max:30',
+ 'email' => 'required|string|email|max:100|unique:users',
+ //'role' => ['required', Rule::in(array_keys(User::$roles))],
+ ];
+ if ($auth_user->isAdmin()) {
+ $rules['role'] = ['required', Rule::in(array_keys(User::$roles))];
+ }
+ if (empty($request->send_invite)) {
+ $rules['password'] = 'required|string|max:255';
+ }
+ $validator = Validator::make($request->all(), $rules);
+
+ if (User::mailboxEmailExists($request->email)) {
+ $invalid = true;
+ $validator->errors()->add('email', __('There is a mailbox with such email. Users and mailboxes can not have the same email addresses.'));
+ }
+
+ if ($invalid || $validator->fails()) {
+ return redirect()->route('users.create')
+ ->withErrors($validator)
+ ->withInput();
+ }
+
+ $user = new User();
+ $user->fill($request->all());
+ if (!$auth_user->can('changeRole', $user)) {
+ $user->role = User::ROLE_USER;
+ }
+ if (empty($request->send_invite)) {
+ // Set password from request
+ $user->password = Hash::make($request->password);
+ } else {
+ // Set some random password before sending invite
+ $user->password = Hash::make($user->generateRandomPassword());
+ }
+ // Set system timezone.
+ $user->timezone = config('app.timezone') ?: User::DEFAULT_TIMEZONE;
+ $user = \Eventy::filter('user.create_save', $user, $request);
+ $user->save();
+
+ $user->mailboxes()->sync($request->mailboxes ?: []);
+ $user->syncPersonalFolders($request->mailboxes);
+
+ // Send invite
+ if (!empty($request->send_invite)) {
+ try {
+ $user->sendInvite(true);
+ } catch (\Exception $e) {
+ // Admin is allowed to see exceptions
+ \Session::flash('flash_error_floating', $e->getMessage().' — '.__('Check mail settings in "Manage » Settings » Mail Settings"'));
+ }
+ }
+
+ \Session::flash('flash_success_floating', __('User created successfully'));
+
+ return redirect()->route('users.profile', ['id' => $user->id]);
+ }
+
+ /**
+ * User profile.
+ */
+ public function profile($id)
+ {
+ $user = User::findOrFail($id);
+ if ($user->isDeleted()) {
+ abort(404);
+ }
+
+ $this->authorize('update', $user);
+
+ $users = $this->getUsersForSidebar($id);
+
+ return view('users/profile', ['user' => $user, 'users' => $users]);
+ }
+
+ public function getUsersForSidebar($except_id)
+ {
+ if (auth()->user()->isAdmin()) {
+ return User::sortUsers(User::nonDeleted()->get());/*->except($except_id)*/;
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * Handle a registration request for the application.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function profileSave($id, Request $request)
+ {
+ $invalid = false;
+
+ $user = User::findOrFail($id);
+ $this->authorize('update', $user);
+
+ // This is also present in PublicController::userSetup
+ $validator = Validator::make($request->all(), [
+ 'first_name' => 'required|string|max:20',
+ 'last_name' => 'required|string|max:30',
+ 'email' => 'required|string|email|max:100|unique:users,email,'.$id,
+ //'emails' => 'max:100',
+ 'job_title' => 'max:100',
+ 'phone' => 'max:60',
+ 'timezone' => 'required|string|max:255',
+ 'time_format' => 'required',
+ 'role' => ['nullable', Rule::in(array_keys(User::$roles))],
+ 'photo_url' => 'nullable|image|mimes:jpeg,png,jpg,gif',
+ ]);
+ $validator->setAttributeNames([
+ 'photo_url' => __('Photo'),
+ ]);
+
+ // Photo
+ $validator->after(function ($validator) use ($user, $request) {
+ if ($request->hasFile('photo_url')) {
+ $path_url = $user->savePhoto($request->file('photo_url'));
+
+ if ($path_url) {
+ $user->photo_url = $path_url;
+ } else {
+ $invalid = true;
+ $validator->errors()->add('photo_url', __('Error occurred processing the image. Make sure that PHP GD extension is enabled.'));
+ }
+ }
+
+ // Do not allow to remove last administrator
+ if ($user->isAdmin() && isset($request->role) && $request->role != User::ROLE_ADMIN) {
+ $admins_count = User::where('role', User::ROLE_ADMIN)->count();
+ if ($admins_count < 2) {
+ $invalid = true;
+ $validator->errors()->add('role', __('Role of the only one administrator can not be changed.'));
+ }
+ }
+ });
+
+ if (User::mailboxEmailExists($request->email)) {
+ $invalid = true;
+ $validator->errors()->add('email', __('There is a mailbox with such email. Users and mailboxes can not have the same email addresses.'));
+ }
+
+ if ($invalid || $validator->fails()) {
+ return redirect()->route('users.profile', ['id' => $id])
+ ->withErrors($validator)
+ ->withInput();
+ }
+
+ // Save language into session.
+ if (auth()->user()->id == $id && $request->locale) {
+ session()->put('user_locale', $request->locale);
+ }
+
+ $request_data = $request->all();
+
+ if (isset($request_data['photo_url'])) {
+ unset($request_data['photo_url']);
+ }
+ if (!auth()->user()->can('changeRole', $user)) {
+ unset($request_data['role']);
+ }
+ if ($user->status != User::STATUS_DELETED) {
+ if (!empty($request_data['disabled'])) {
+ $request_data['status'] = User::STATUS_DISABLED;
+ } else {
+ $request_data['status'] = User::STATUS_ACTIVE;
+ }
+ }
+ $user->setData($request_data);
+
+ if (empty($request->input('enable_kb_shortcuts'))) {
+ $user->enable_kb_shortcuts = false;
+ }
+
+ $user = \Eventy::filter('user.save_profile', $user, $request);
+
+ $user->save();
+
+ \Session::flash('flash_success_floating', __('Profile saved successfully'));
+
+ return redirect()->route('users.profile', ['id' => $id]);
+ }
+
+ /**
+ * User permissions.
+ */
+ public function permissions($id)
+ {
+ $user = auth()->user();
+ if (!$user->isAdmin()) {
+ abort(403);
+ }
+
+ $user = User::findOrFail($id);
+
+ $mailboxes = Mailbox::all();
+
+ $users = $this->getUsersForSidebar($id);
+
+ return view('users/permissions', [
+ 'user' => $user,
+ 'mailboxes' => $mailboxes,
+ 'user_mailboxes' => $user->mailboxes,
+ 'users' => $users,
+ ]);
+ }
+
+ /**
+ * Save user permissions.
+ *
+ * @param int $id
+ * @param \Illuminate\Http\Request $request
+ */
+ public function permissionsSave($id, Request $request)
+ {
+ $user = auth()->user();
+ if (!$user->isAdmin()) {
+ abort(403);
+ }
+
+ $user = User::findOrFail($id);
+
+ $user->mailboxes()->sync($request->mailboxes ?: []);
+ $user->syncPersonalFolders($request->mailboxes);
+
+ // Save permissions.
+ $user_permissions = $request->user_permissions ?? [];
+ $permissions = [];
+
+ foreach (User::getUserPermissionsList() as $permission_id) {
+ $new_has_permission = in_array($permission_id, $user_permissions);
+
+ if ($user->hasPermission($permission_id, false) != $new_has_permission) {
+ $permissions[$permission_id] = (int)(bool)$new_has_permission;
+ $save_user = true;
+ }
+ }
+ $user->permissions = $permissions;
+ $user->save();
+
+ \Session::flash('flash_success_floating', __('Permissions saved successfully'));
+
+ return redirect()->route('users.permissions', ['id' => $id]);
+ }
+
+ /**
+ * User notifications settings.
+ */
+ public function notifications($id)
+ {
+ $user = User::findOrFail($id);
+ $this->authorize('update', $user);
+
+ $subscriptions = $user->subscriptions()->select('medium', 'event')->get();
+
+ $person = '';
+ if ($id != auth()->user()->id) {
+ $person = $user->getFirstName(true);
+ }
+
+ $users = $this->getUsersForSidebar($id);
+
+ return view('users/notifications', [
+ 'user' => $user,
+ 'subscriptions' => $subscriptions,
+ 'person' => $person,
+ 'users' => $users,
+ 'mobile_available' => \Eventy::filter('notifications.mobile_available', false),
+ ]);
+ }
+
+ /**
+ * Save user notifications settings.
+ *
+ * @param int $id
+ * @param \Illuminate\Http\Request $request
+ */
+ public function notificationsSave($id, Request $request)
+ {
+ $user = User::findOrFail($id);
+ $this->authorize('update', $user);
+
+ Subscription::saveFromArray($request->subscriptions, $user->id);
+
+ \Session::flash('flash_success_floating', __('Notifications saved successfully'));
+
+ return redirect()->route('users.notifications', ['id' => $id]);
+ }
+
+ /**
+ * Users ajax controller.
+ */
+ public function ajax(Request $request)
+ {
+ $response = [
+ 'status' => 'error',
+ 'msg' => '', // this is error message
+ ];
+
+ $auth_user = auth()->user();
+
+ switch ($request->action) {
+
+ // Both send and resend
+ case 'send_invite':
+ if (!$auth_user->isAdmin()) {
+ $response['msg'] = __('Not enough permissions');
+ }
+ if (empty($request->user_id)) {
+ $response['msg'] = __('Incorrect user');
+ }
+ if (!$response['msg']) {
+ $user = User::find($request->user_id);
+ if (!$user) {
+ $response['msg'] = __('User not found');
+ } elseif ($user->invite_state == User::INVITE_STATE_ACTIVATED) {
+ $response['msg'] = __('User already accepted invitation');
+ }
+ }
+
+ if (!$response['msg']) {
+ try {
+ $user->sendInvite(true);
+
+ $response['status'] = 'success';
+ } catch (\Exception $e) {
+ // Admin is allowed to see exceptions.
+ $response['msg'] = $e->getMessage().' — '.__('Check mail settings in "Manage » Settings » Mail Settings"');
+ }
+ }
+ break;
+
+ // Reset password
+ case 'reset_password':
+ if (!auth()->user()->isAdmin()) {
+ $response['msg'] = __('Not enough permissions');
+ }
+ if (empty($request->user_id)) {
+ $response['msg'] = __('Incorrect user');
+ }
+ if (!$response['msg']) {
+ $user = User::find($request->user_id);
+ if (!$user) {
+ $response['msg'] = __('User not found');
+ }
+ }
+
+ if (!$response['msg']) {
+ $reset_result = Password::broker()->sendResetLink(
+ //['id' => $request->user_id]
+ ['id' => $request->user_id]
+ );
+
+ if ($reset_result == Password::RESET_LINK_SENT) {
+ $response['status'] = 'success';
+ $response['msg_success'] = __('Password reset email has been sent');
+ }
+ }
+ break;
+
+ // Load website notifications
+ case 'web_notifications':
+ if (!$auth_user) {
+ $response['msg'] = __('You are not logged in');
+ }
+ if (!$response['msg']) {
+ $web_notifications_info = $auth_user->getWebsiteNotificationsInfo(false);
+ $response['html'] = view('users/partials/web_notifications', [
+ 'web_notifications_info_data' => $web_notifications_info['data'],
+ ])->render();
+
+ $response['has_more_pages'] = (int) $web_notifications_info['notifications']->hasMorePages();
+
+ $response['status'] = 'success';
+ }
+ break;
+
+ // Mark all user website notifications as read
+ case 'mark_notifications_as_read':
+ if (!$auth_user) {
+ $response['msg'] = __('You are not logged in');
+ }
+ if (!$response['msg']) {
+ $auth_user->unreadNotifications()->update(['read_at' => now()]);
+ $auth_user->clearWebsiteNotificationsCache();
+
+ $response['status'] = 'success';
+ }
+ break;
+
+ // Delete user photo
+ case 'delete_photo':
+ $user = User::find($request->user_id);
+
+ if (!$user) {
+ $response['msg'] = __('User not found');
+ } elseif (!$auth_user->can('update', $user)) {
+ $response['msg'] = __('Not enough permissions');
+ }
+ if (!$response['msg']) {
+ $user->removePhoto();
+ $user->save();
+
+ $response['status'] = 'success';
+ }
+ break;
+
+ // Delete user
+ case 'delete_user':
+ $user = User::find($request->user_id);
+
+ if (!$user) {
+ $response['msg'] = __('User not found');
+ } elseif (!$auth_user->can('delete', $user)) {
+ $response['msg'] = __('Not enough permissions');
+ }
+
+ // Check if the user is the only one admin
+ if (!$response['msg'] && $user->isAdmin()) {
+ $admins_count = User::where('role', User::ROLE_ADMIN)->count();
+ if ($admins_count < 2) {
+ $response['msg'] = __('Administrator can not be deleted');
+ }
+ }
+
+ if (!$response['msg']) {
+
+ // We have to process conversations one by one to move them to Unassigned folder,
+ // as conversations may be in different mailboxes
+ // $user->conversations()->update(['user_id' => null, 'folder_id' => ]);
+ $mailbox_unassigned_folders = [];
+
+ $user->conversations->each(function ($conversation) use ($auth_user, $request) {
+ // We don't fire ConversationUserChanged event to avoid sending notifications to users
+ if (!empty($request->assign_user)
+ && !empty($request->assign_user[$conversation->mailbox_id])
+ && (int) $request->assign_user[$conversation->mailbox_id] != -1
+ ) {
+ // Set assignee.
+ // In this case conversation stays assigned, just assignee changes.
+ $conversation->user_id = $request->assign_user[$conversation->mailbox_id];
+
+ } else {
+
+ // Make convesation Unassigned.
+
+ // Unset assignee.
+ // Maybe use changeUser() here.
+ $conversation->user_id = null;
+
+ if ($conversation->isPublished()
+ && ($conversation->isActive() || $conversation->isPending())
+ ) {
+ // Change conversation folder to UNASSIGNED.
+ $folder_id = null;
+ if (!empty($mailbox_unassigned_folders[$conversation->mailbox_id])) {
+ $folder_id = $mailbox_unassigned_folders[$conversation->mailbox_id];
+ } else {
+ $folder = $conversation->mailbox->folders()
+ ->where('type', Folder::TYPE_UNASSIGNED)
+ ->first();
+
+ if ($folder) {
+ $folder_id = $folder->id;
+ $mailbox_unassigned_folders[$conversation->mailbox_id] = $folder_id;
+ }
+ }
+ if ($folder_id) {
+ $conversation->folder_id = $folder_id;
+ }
+ }
+ }
+
+ $conversation->save();
+
+ // Create lineitem thread
+ $thread = new Thread();
+ $thread->conversation_id = $conversation->id;
+ $thread->user_id = $conversation->user_id;
+ $thread->type = Thread::TYPE_LINEITEM;
+ $thread->state = Thread::STATE_PUBLISHED;
+ $thread->status = Thread::STATUS_NOCHANGE;
+ $thread->action_type = Thread::ACTION_TYPE_USER_CHANGED;
+ $thread->source_via = Thread::PERSON_USER;
+ $thread->source_type = Thread::SOURCE_TYPE_WEB;
+ $thread->customer_id = $conversation->customer_id;
+ $thread->created_by_user_id = $auth_user->id;
+ $thread->save();
+ });
+
+ // Recalculate counters for folders
+ //if ($user->isAdmin()) {
+ // Admin has access to all mailboxes
+ Mailbox::all()->each(function ($mailbox) {
+ $mailbox->updateFoldersCounters();
+ });
+ // } else {
+ // $user->mailboxes->each(function ($mailbox) {
+ // $mailbox->updateFoldersCounters();
+ // });
+ // }
+
+ // Disconnect user from mailboxes.
+ $user->mailboxes()->sync([]);
+ $user->folders()->delete();
+
+ $user->status = \App\User::STATUS_DELETED;
+ // Update email.
+ $email_suffix = User::EMAIL_DELETED_SUFFIX.date('YmdHis');
+ // We have to truncate email to avoid "Data too long" error.
+ $user->email = mb_substr($user->email, 0, User::EMAIL_MAX_LENGTH - mb_strlen($email_suffix)).$email_suffix;
+
+ $user->save();
+
+ event(new UserDeleted($user, $auth_user));
+
+ \Session::flash('flash_success_floating', __('User deleted').': '.$user->getFullName());
+
+ $response['status'] = 'success';
+ }
+ break;
+
+ default:
+ $response['msg'] = 'Unknown action';
+ break;
+ }
+
+ if ($response['status'] == 'error' && empty($response['msg'])) {
+ $response['msg'] = 'Unknown error occurred';
+ }
+
+ return \Response::json($response);
+ }
+
+ /**
+ * Change user password.
+ */
+ public function password($id)
+ {
+ $user = User::findOrFail($id);
+ $this->authorize('update', $user);
+
+ $users = User::all()->except($id);
+
+ return view('users/password', ['user' => $user, 'users' => $users]);
+ }
+
+ /**
+ * Save changed user password.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function passwordSave($id, Request $request)
+ {
+ // It is allowed to edit only your own password
+ $user = auth()->user();
+ if ($user->id != $id) {
+ abort(403);
+ }
+
+ // This is also present in PublicController::userSetup
+ $validator = Validator::make($request->all(), [
+ 'password_current' => 'required|string',
+ 'password' => 'required|string|min:8|confirmed',
+ ]);
+
+ $validator->after(function ($validator) use ($user, $request) {
+ // Check current password
+ if (!Hash::check($request->password_current, $user->password)) {
+ $validator->errors()->add('password_current', __('This password is incorrect.'));
+ } elseif (Hash::check($request->password, $user->password)) {
+ // Check new password
+ $validator->errors()->add('password', __('The new password is the same as the old password.'));
+ }
+ });
+
+ if ($validator->fails()) {
+ return redirect()->route('users.password', ['id' => $id])
+ ->withErrors($validator)
+ ->withInput();
+ }
+
+ $user->password = bcrypt($request->password);
+ $user->save();
+
+ $user->sendPasswordChanged();
+
+ \Session::flash('flash_success_floating', __('Password saved successfully!'));
+
+ return redirect()->route('users.profile', ['id' => $id]);
+ }
+}
diff --git a/freescout-dist/app/Http/Kernel.php b/freescout-dist/app/Http/Kernel.php
new file mode 100644
index 0000000..5474ded
--- /dev/null
+++ b/freescout-dist/app/Http/Kernel.php
@@ -0,0 +1,70 @@
+ [
+ \App\Http\Middleware\EncryptCookies::class,
+ \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
+ \Illuminate\Session\Middleware\StartSession::class,
+ \App\Http\Middleware\TokenAuth::class,
+ // \Illuminate\Session\Middleware\AuthenticateSession::class,
+ \Illuminate\View\Middleware\ShareErrorsFromSession::class,
+ \App\Http\Middleware\VerifyCsrfToken::class,
+ \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ \App\Http\Middleware\HttpsRedirect::class,
+ \App\Http\Middleware\Localize::class,
+ \App\Http\Middleware\LogoutIfDeleted::class,
+ \App\Http\Middleware\FrameGuard::class,
+ \App\Http\Middleware\CustomHandle::class,
+ ],
+
+ // 'api' => [
+ // 'throttle:60,1',
+ // 'bindings',
+ // ],
+ ];
+
+ /**
+ * The application's route middleware.
+ *
+ * These middleware may be assigned to groups or used individually.
+ *
+ * @var array
+ */
+ protected $routeMiddleware = [
+ 'auth' => \Illuminate\Auth\Middleware\Authenticate::class,
+ 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
+ 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ 'can' => \Illuminate\Auth\Middleware\Authorize::class,
+ 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
+ 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
+ 'roles' => \App\Http\Middleware\CheckRole::class,
+ ];
+}
diff --git a/freescout-dist/app/Http/Middleware/CheckRole.php b/freescout-dist/app/Http/Middleware/CheckRole.php
new file mode 100644
index 0000000..ae9b7ea
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/CheckRole.php
@@ -0,0 +1,43 @@
+getRequiredRoleForRoute($request->route());
+
+ // Check if a role is required for the route, and
+ // if so, ensure that the user has that role.
+ if (!$roles || in_array($request->user()->getRoleName(), $roles)) {
+ return $next($request);
+ }
+ abort(403, __('You are not authorized to access this resource.'));
+ // return response([
+ // 'error' => [
+ // 'code' => 'INSUFFICIENT_ROLE',
+ // 'description' =>
+ // ]
+ // ], 401);
+ }
+
+ private function getRequiredRoleForRoute($route)
+ {
+ $actions = $route->getAction();
+
+ return isset($actions['roles']) ? $actions['roles'] : null;
+ }
+}
diff --git a/freescout-dist/app/Http/Middleware/CustomHandle.php b/freescout-dist/app/Http/Middleware/CustomHandle.php
new file mode 100644
index 0000000..7fd9681
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/CustomHandle.php
@@ -0,0 +1,29 @@
+exists('chat_mode')) {
+ \Helper::setChatMode((int)$request->chat_mode);
+ }
+
+ // Hook.
+ \Eventy::action('middleware.web.custom_handle', $request);
+
+ return \Eventy::filter('middleware.web.custom_handle.response', $next($request), $request, $next);
+ }
+}
diff --git a/freescout-dist/app/Http/Middleware/EncryptCookies.php b/freescout-dist/app/Http/Middleware/EncryptCookies.php
new file mode 100644
index 0000000..1dcaf75
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/EncryptCookies.php
@@ -0,0 +1,17 @@
+headers->set('X-Frame-Options', $value, false);
+ }
+
+ return $response;
+ }
+}
diff --git a/freescout-dist/app/Http/Middleware/HttpsRedirect.php b/freescout-dist/app/Http/Middleware/HttpsRedirect.php
new file mode 100644
index 0000000..e737803
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/HttpsRedirect.php
@@ -0,0 +1,48 @@
+ 'FORWARDED',
+ // Request::HEADER_X_FORWARDED_FOR => 'X_FORWARDED_FOR',
+ // Request::HEADER_X_FORWARDED_HOST => 'X_FORWARDED_HOST',
+ // Request::HEADER_X_FORWARDED_PORT => 'X_FORWARDED_PORT',
+ // Request::HEADER_X_FORWARDED_PROTO => 'X_FORWARDED_PROTO',
+ // ];
+
+ public function handle($request, Closure $next)
+ {
+ if (\Helper::isHttps()) {
+ //$request->setTrustedProxies( [ $request->getClientIp() ], array_keys($this->headers));
+ //!$request->secure()
+ if (!\Helper::isCurrentUrlHttps()) {
+ return redirect()->secure($request->getRequestUri());
+ }
+ }
+
+ // Correct protocol in $_SERVER
+ if (\Helper::isHttps()
+ //&& !$request->secure()
+ && strtolower($_SERVER['HTTPS'] ?? '') != 'on'
+ ) {
+ $_SERVER['HTTPS'] = 'on';
+ }
+ return $next($request);
+ }
+}
\ No newline at end of file
diff --git a/freescout-dist/app/Http/Middleware/Localize.php b/freescout-dist/app/Http/Middleware/Localize.php
new file mode 100644
index 0000000..329c6c1
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/Localize.php
@@ -0,0 +1,29 @@
+isDeleted() || $user->isDisabled())) {
+ Auth::logout();
+ return Redirect::route('login');
+ }
+
+ return $next($request);
+ }
+}
diff --git a/freescout-dist/app/Http/Middleware/RedirectIfAuthenticated.php b/freescout-dist/app/Http/Middleware/RedirectIfAuthenticated.php
new file mode 100644
index 0000000..afe1c26
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/RedirectIfAuthenticated.php
@@ -0,0 +1,27 @@
+check()) {
+ return redirect('/home');
+ }
+
+ return $next($request);
+ }
+}
diff --git a/freescout-dist/app/Http/Middleware/ResponseHeaders.php b/freescout-dist/app/Http/Middleware/ResponseHeaders.php
new file mode 100644
index 0000000..68e9465
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/ResponseHeaders.php
@@ -0,0 +1,21 @@
+header('Pragma', 'no-cache');
+ $response->header('Cache-Control', 'no-cache, max-age=0, must-revalidate, no-store');
+ }
+
+ return $response;
+ }
+}
diff --git a/freescout-dist/app/Http/Middleware/TerminateHandler.php b/freescout-dist/app/Http/Middleware/TerminateHandler.php
new file mode 100644
index 0000000..e4106e1
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/TerminateHandler.php
@@ -0,0 +1,20 @@
+user() && !empty($request->auth_token) && $request->cookie('in_app')) {
+ try {
+ $user = User::where(\DB::raw('md5(CONCAT(id, created_at, "'.config('app.key').'"))'), $request->auth_token)
+ ->first();
+ } catch (\Exception $e) {
+ \Helper::logException($e, '[TokenAuth]');
+ }
+ if (!empty($user)) {
+ \Auth::login($user);
+ }
+ }
+ return $next($request);
+ }
+}
diff --git a/freescout-dist/app/Http/Middleware/TrimStrings.php b/freescout-dist/app/Http/Middleware/TrimStrings.php
new file mode 100644
index 0000000..5a50e7b
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/TrimStrings.php
@@ -0,0 +1,18 @@
+ 'FORWARDED',
+ Request::HEADER_X_FORWARDED_FOR => 'X_FORWARDED_FOR',
+ Request::HEADER_X_FORWARDED_HOST => 'X_FORWARDED_HOST',
+ Request::HEADER_X_FORWARDED_PORT => 'X_FORWARDED_PORT',
+ Request::HEADER_X_FORWARDED_PROTO => 'X_FORWARDED_PROTO',
+ ];
+}
diff --git a/freescout-dist/app/Http/Middleware/VerifyCsrfToken.php b/freescout-dist/app/Http/Middleware/VerifyCsrfToken.php
new file mode 100644
index 0000000..0c13b85
--- /dev/null
+++ b/freescout-dist/app/Http/Middleware/VerifyCsrfToken.php
@@ -0,0 +1,17 @@
+payload_decoded !== null) {
+ return $this->payload_decoded;
+ }
+
+ $this->payload_decoded = json_decode($this->payload, true);
+
+ return $this->payload_decoded;
+ }
+
+ public function getCommand()
+ {
+ return self::getPayloadCommand($this->getPayloadDecoded());
+ }
+
+ public function getCommandLastThread()
+ {
+ $command = $this->getCommand();
+ if ($command && !empty($command->threads)) {
+ return Thread::getLastThread($command->threads);
+ }
+
+ return null;
+ }
+
+ public static function getPayloadCommand($payload)
+ {
+ if (empty($payload['data']) || empty($payload['data']['command'])) {
+ return null;
+ }
+ try {
+ // If some record has been deleted from DB, there will be an error:
+ // No query results for model [App\Conversation].
+ return unserialize($payload['data']['command']);
+ } catch (\Exception $e) {
+ return null;
+ }
+ }
+}
diff --git a/freescout-dist/app/Jobs/RestartQueueWorker.php b/freescout-dist/app/Jobs/RestartQueueWorker.php
new file mode 100644
index 0000000..d32dafd
--- /dev/null
+++ b/freescout-dist/app/Jobs/RestartQueueWorker.php
@@ -0,0 +1,41 @@
+delete();
+ // register_shutdown_function() is called on exit(),
+ // so commands mutexes are removed.
+ exit();
+ }
+}
diff --git a/freescout-dist/app/Jobs/SendAlert.php b/freescout-dist/app/Jobs/SendAlert.php
new file mode 100644
index 0000000..da9ffb7
--- /dev/null
+++ b/freescout-dist/app/Jobs/SendAlert.php
@@ -0,0 +1,105 @@
+text = $text;
+ $this->title = $title;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ // Configure mail driver according to Mailbox settings
+ \MailHelper::setSystemMailDriver();
+
+ $recipients = User::nonDeleted()
+ ->where('role', User::ROLE_ADMIN)
+ ->where('invite_state', User::INVITE_STATE_ACTIVATED)
+ ->pluck('email')
+ ->toArray();
+
+ $extra = \MailHelper::sanitizeEmails(\Option::get('alert_recipients'));
+ if ($extra) {
+ $recipients = array_unique(array_merge($recipients, $extra));
+ }
+
+ foreach ($recipients as $recipient) {
+ $exception = null;
+
+ try {
+ Mail::to([['name' => '', 'email' => $recipient]])
+ ->send(new Alert($this->text, $this->title));
+ } catch (\Exception $e) {
+ // We come here in case SMTP server unavailable for example
+ activity()
+ ->withProperties([
+ 'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
+ ])
+ ->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
+ ->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_ALERT);
+
+ $exception = $e;
+ }
+
+ $status_message = '';
+ if ($exception) {
+ $status = SendLog::STATUS_SEND_ERROR;
+ $status_message = $exception->getMessage();
+ } else {
+ $failures = Mail::failures();
+
+ if (!empty($failures)) {
+ $status = SendLog::STATUS_SEND_ERROR;
+ } else {
+ $status = SendLog::STATUS_ACCEPTED;
+ }
+ }
+
+ SendLog::log(null, null, $recipient, SendLog::MAIL_TYPE_ALERT, $status, null, null, $status_message);
+ }
+
+ if ($exception) {
+ throw $exception;
+ }
+ }
+}
diff --git a/freescout-dist/app/Jobs/SendAutoReply.php b/freescout-dist/app/Jobs/SendAutoReply.php
new file mode 100644
index 0000000..07899e7
--- /dev/null
+++ b/freescout-dist/app/Jobs/SendAutoReply.php
@@ -0,0 +1,148 @@
+conversation = $conversation;
+ $this->thread = $thread;
+ $this->mailbox = $mailbox;
+ $this->customer = $customer;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ // Auto reply disabled.
+ if (!empty($this->conversation->meta['ar_off'])) {
+ return;
+ }
+
+ // Configure mail driver according to Mailbox settings
+ \App\Misc\Mail::setMailDriver($this->mailbox, null, $this->conversation);
+
+ // Auto reply appears as reply in customer's mailbox
+ $headers['In-Reply-To'] = '<'.$this->thread->message_id.'>';
+ $headers['References'] = '<'.$this->thread->message_id.'>';
+
+ // Create Message-ID for the auto reply
+ $message_id = \App\Misc\Mail::MESSAGE_ID_PREFIX_AUTO_REPLY.'-'.$this->thread->id.'-'.\MailHelper::getMessageIdHash($this->thread->id).'@'.$this->mailbox->getEmailDomain();
+ $headers['Message-ID'] = $message_id;
+
+ $customer_email = $this->conversation->customer_email;
+
+ if (!$customer_email) {
+ // When message is received via Chat, customer has no email adddress.
+ return;
+ }
+ $recipients = [$customer_email];
+ $failures = [];
+ $exception = null;
+
+ try {
+ Mail::to([['name' => $this->customer->getFullName(), 'email' => $customer_email]])
+ ->send(new AutoReply($this->conversation, $this->mailbox, $this->customer, $headers));
+ } catch (\Exception $e) {
+ // We come here in case SMTP server unavailable for example
+ activity()
+ ->causedBy($this->customer)
+ ->withProperties([
+ 'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
+ ])
+ ->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
+ ->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
+
+ // Failures will be saved to send log when retry attempts will finish
+ $failures = $recipients;
+
+ $exception = $e;
+ }
+
+ foreach ($recipients as $recipient) {
+ $status_message = '';
+ if ($exception) {
+ $status = SendLog::STATUS_SEND_ERROR;
+ $status_message = $exception->getMessage();
+ } else {
+ $failures = Mail::failures();
+
+ // Status for send log
+ if (!empty($failures) && in_array($recipient, $failures)) {
+ $status = SendLog::STATUS_SEND_ERROR;
+ } else {
+ $status = SendLog::STATUS_ACCEPTED;
+ }
+ }
+ if ($customer_email == $recipient) {
+ $customer_id = $this->customer->id;
+ } else {
+ $customer_id = null;
+ }
+
+ SendLog::log($this->thread->id, $message_id, $recipient, SendLog::MAIL_TYPE_AUTO_REPLY, $status, $customer_id, null, $status_message);
+ }
+
+ if ($exception) {
+ throw $exception;
+ }
+ }
+
+ /**
+ * The job failed to process.
+ * This method is called after attempts had finished.
+ * At this stage method has access only to variables passed in constructor.
+ *
+ * @param Exception $exception
+ *
+ * @return void
+ */
+ public function failed(\Exception $e)
+ {
+ // Write to activity log
+ activity()
+ ->causedBy($this->customer)
+ ->withProperties([
+ 'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
+ ])
+ ->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
+ ->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
+ }
+}
diff --git a/freescout-dist/app/Jobs/SendEmailReplyError.php b/freescout-dist/app/Jobs/SendEmailReplyError.php
new file mode 100644
index 0000000..ae86cf6
--- /dev/null
+++ b/freescout-dist/app/Jobs/SendEmailReplyError.php
@@ -0,0 +1,95 @@
+from = $from;
+ $this->user = $user;
+ $this->mailbox = $mailbox;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ // Configure mail driver according to Mailbox settings
+ \App\Misc\Mail::setMailDriver($this->mailbox);
+
+ $exception = null;
+
+ try {
+ Mail::to([['name' => '', 'email' => $this->from]])
+ ->send(new UserEmailReplyError());
+ } catch (\Exception $e) {
+ // We come here in case SMTP server unavailable for example
+ activity()
+ ->withProperties([
+ 'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
+ ])
+ ->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
+ ->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_WRONG_EMAIL);
+
+ $exception = $e;
+ }
+
+ $status_message = '';
+ if ($exception) {
+ $status = SendLog::STATUS_SEND_ERROR;
+ $status_message = $exception->getMessage();
+ } else {
+ $failures = Mail::failures();
+
+ // Save to send log
+ if (!empty($failures)) {
+ $status = SendLog::STATUS_SEND_ERROR;
+ } else {
+ $status = SendLog::STATUS_ACCEPTED;
+ }
+ }
+
+ SendLog::log(null, null, $this->from, SendLog::MAIL_TYPE_WRONG_USER_EMAIL_MESSAGE, $status, null, $this->user->id, $status_message);
+
+ if ($exception) {
+ throw $exception;
+ }
+ }
+}
diff --git a/freescout-dist/app/Jobs/SendNotificationToUsers.php b/freescout-dist/app/Jobs/SendNotificationToUsers.php
new file mode 100644
index 0000000..046fe7e
--- /dev/null
+++ b/freescout-dist/app/Jobs/SendNotificationToUsers.php
@@ -0,0 +1,223 @@
+users = $users;
+ $this->conversation = $conversation;
+ $this->threads = $threads;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $mailbox = $this->conversation->mailbox;
+
+ // Configure mail driver according to Mailbox settings
+ \App\Misc\Mail::setMailDriver($mailbox, null, $this->conversation);
+
+ // Threads has to be sorted here, if sorted before, they come here in wrong order
+ $this->threads = Thread::sortThreads($this->threads);
+
+ $headers = [];
+ $last_thread = $this->threads->first();
+
+ if (!$last_thread) {
+ return;
+ }
+
+ // If thread is draft, it means it has been undone
+ if ($last_thread->isDraft()) {
+ return;
+ }
+
+ // Limit conversation history
+ if (config('app.email_user_history') == 'last') {
+ $this->threads = $this->threads->slice(0, 2);
+ }
+
+ if (config('app.email_user_history') == 'none') {
+ $this->threads = $this->threads->slice(0, 1);
+ }
+
+ // All notification for the same conversation has same dummy Message-ID
+ $prev_message_id = \App\Misc\Mail::MESSAGE_ID_PREFIX_NOTIFICATION_IN_REPLY.'-'.$this->conversation->id.'-'.md5($this->conversation->id).'@'.$mailbox->getEmailDomain();
+ $headers['In-Reply-To'] = '<'.$prev_message_id.'>';
+ $headers['References'] = '<'.$prev_message_id.'>';
+ // https://github.com/freescout-helpdesk/freescout/issues/2488
+ $headers['X-Auto-Response-Suppress'] = 'All';
+
+ // We throw an exception if any of the send attempts throws an exception (connection error, etc)
+ $global_exception = null;
+
+ foreach ($this->users as $user) {
+
+ // User can ne deleted from DB.
+ if (!isset($user->id)) {
+ continue;
+ }
+
+ if ($user->isDeleted()) {
+ continue;
+ }
+
+ // If for one user sending fails the job is marked as failed and retried after some time.
+ // So we have to check if notification email has already been successfully sent to this user.
+ if ($this->attempts() > 1) {
+ // Maybe add indexes to the table.
+ $already_sent = SendLog::where('thread_id', $last_thread->id)
+ ->where('mail_type', SendLog::MAIL_TYPE_USER_NOTIFICATION)
+ ->where('user_id', $user->id)
+ ->whereIn('status', SendLog::$sent_success)
+ ->exists();
+ if ($already_sent) {
+ continue;
+ }
+ }
+
+ $message_id = \App\Misc\Mail::MESSAGE_ID_PREFIX_NOTIFICATION.'-'.$last_thread->id.'-'.$user->id.'-'.time().'@'.$mailbox->getEmailDomain();
+ $headers['Message-ID'] = $message_id;
+
+ // If this is notification on message from customer, set customer as sender name
+ $from_name = '';
+ if ($last_thread->type == Thread::TYPE_CUSTOMER) {
+ $from_name = '';
+ if ($last_thread->customer) {
+ $from_name = $last_thread->customer->getFullName(true, true);
+ }
+ if ($from_name) {
+ $from_name = $from_name.' '.__('via').' '.$mailbox->name;
+ }
+ }
+ if (!$from_name) {
+ $from_name = $mailbox->name;
+ }
+ $from = ['address' => $mailbox->email, 'name' => $from_name];
+
+ // Set user language
+ app()->setLocale($user->getLocale());
+
+ $headers['X-FreeScout-Mail-Type'] = 'user.notification';
+ $headers = \Eventy::filter('jobs.send_reply_to_customer.headers', $headers, $user, $mailbox, $this->conversation, $this->threads, $from);
+
+ $exception = null;
+
+ try {
+ Mail::to([['name' => $user->getFullName(), 'email' => $user->email]])
+ ->send(new UserNotification($user, $this->conversation, $this->threads, $headers, $from, $mailbox));
+ } catch (\Exception $e) {
+ // We come here in case SMTP server unavailable for example
+ activity()
+ ->causedBy($user)
+ ->withProperties([
+ 'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
+ ])
+ ->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
+ ->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_USER);
+
+ $exception = $e;
+ $global_exception = $e;
+ }
+
+ $status_message = '';
+ if ($exception) {
+ $status = SendLog::STATUS_SEND_ERROR;
+ $status_message = $exception->getMessage();
+ } else {
+ $failures = Mail::failures();
+
+ // Save to send log
+ if (!empty($failures) && in_array($user->email, $failures)) {
+ $status = SendLog::STATUS_SEND_ERROR;
+ } else {
+ $status = SendLog::STATUS_ACCEPTED;
+ }
+ }
+
+ SendLog::log($last_thread->id, $message_id, $user->email, SendLog::MAIL_TYPE_USER_NOTIFICATION, $status, null, $user->id, $status_message);
+ }
+
+ if ($global_exception) {
+ // Retry job with delay.
+ // https://stackoverflow.com/questions/35258175/how-can-i-create-delays-between-failed-queued-job-attempts-in-laravel
+ // We do not try to resend Bounce messages: https://github.com/freescout-helpdesk/freescout/issues/3156
+ if ($this->attempts() < $this->tries && !$last_thread->isBounce()) {
+ if ($this->attempts() == 1) {
+ // Second attempt after 5 min.
+ $this->release(300);
+ } else {
+ // Others - after 1 hour.
+ $this->release(3600);
+ }
+
+ throw $global_exception;
+ } else {
+ $this->fail($global_exception);
+
+ return;
+ }
+ }
+ }
+
+ /**
+ * The job failed to process.
+ * This method is called after attempts had finished.
+ * At this stage method has access only to variables passed in constructor.
+ *
+ * @param Exception $exception
+ *
+ * @return void
+ */
+ public function failed(\Exception $e)
+ {
+ // Write to activity log
+ activity()
+ //->causedBy($this->customer)
+ ->withProperties([
+ 'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
+ ])
+ ->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
+ ->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_USER);
+ }
+}
diff --git a/freescout-dist/app/Jobs/SendReplyToCustomer.php b/freescout-dist/app/Jobs/SendReplyToCustomer.php
new file mode 100644
index 0000000..6f813c1
--- /dev/null
+++ b/freescout-dist/app/Jobs/SendReplyToCustomer.php
@@ -0,0 +1,552 @@
+conversation = $conversation;
+ $this->threads = $threads;
+ $this->customer = $customer;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $send_previous_messages = false;
+ $is_forward = false;
+
+ // When forwarding conversation is undone, new conversation is deleted.
+ if (!$this->conversation) {
+ return;
+ }
+
+ $mailbox = $this->conversation->mailbox;
+
+ // Mailbox may be deleted.
+ if (!$mailbox) {
+ return;
+ }
+
+ // Add forwarded conversation replies.
+ if ($this->conversation->threads_count == 1 && count($this->threads) == 1) {
+ $forward_child_thread = $this->threads[0];
+ if ($forward_child_thread->isForwarded() && $forward_child_thread->getForwardParentConversation()) {
+
+ // Add replies from original conversation.
+ $forwarded_replies = $forward_child_thread->getForwardParentConversation()->getReplies();
+ $forwarded_replies = Thread::sortThreads($forwarded_replies);
+ $forward_parent_thread = Thread::find($forward_child_thread->getMetaFw(Thread::META_FORWARD_PARENT_THREAD_ID));
+
+ if ($forward_parent_thread) {
+ // Remove threads created after forwarding.
+ foreach ($forwarded_replies as $i => $thread) {
+ if ($thread->created_at > $forward_parent_thread->created_at) {
+ $forwarded_replies->forget($i);
+ }
+ }
+ $this->threads = $this->threads->merge($forwarded_replies);
+ $is_forward = true;
+ }
+ }
+ }
+
+ // Threads has to be sorted here, if sorted before, they come here in wrong order
+ $this->threads = Thread::sortThreads($this->threads);
+
+ $new = false;
+ $headers = [];
+
+ $this->last_thread = $this->threads->first();
+
+ if ($this->last_thread === null) {
+ return;
+ }
+ $last_customer_thread = null;
+
+ // If thread is draft, it means it has been undone
+ if ($this->last_thread->isDraft()) {
+ return;
+ }
+
+ if (count($this->threads) == 1) {
+ $new = true;
+ }
+ if (!$new) {
+ $i = 0;
+ foreach ($this->threads as $thread) {
+ if ($i > 0 && $thread->type == Thread::TYPE_CUSTOMER) {
+ $last_customer_thread = $thread;
+ break;
+ }
+ $i++;
+ }
+ }
+
+ // In-Reply-To and References headers.
+ $references = '';
+ if (!$new && !empty($last_customer_thread) && $last_customer_thread->message_id) {
+
+ $headers['In-Reply-To'] = '<'.$last_customer_thread->message_id.'>';
+ //$headers['References'] = '<'.$last_customer_thread->message_id.'>';
+ // https://github.com/freescout-helpdesk/freescout/issues/3175
+ $i = 0;
+ $references_array = [];
+ foreach ($this->threads as $thread) {
+ if ($i > 0) {
+ $reference = $thread->getMessageId();
+ if ($reference) {
+ $references_array[] = $reference;
+ }
+ }
+ $i++;
+ }
+ if ($references_array) {
+ $references = '<'.implode('> <', array_reverse($references_array)).'>';
+ }
+ if ($references) {
+ $headers['References'] = $references;
+ }
+ }
+
+ // Conversation history.
+ $email_conv_history = config('app.email_conv_history');
+
+ $threads_count = count($this->threads);
+
+ $meta_conv_history = $this->last_thread->getMeta(Thread::META_CONVERSATION_HISTORY);
+ if (!empty($meta_conv_history)) {
+ $email_conv_history = $meta_conv_history;
+ }
+
+ if ($is_forward && $email_conv_history == 'global') {
+ $email_conv_history = 'full';
+ }
+
+ if ($is_forward && $email_conv_history == 'none') {
+ $email_conv_history = 'full';
+ }
+
+ if ($email_conv_history == 'full') {
+ $send_previous_messages = true;
+ }
+
+ if ($email_conv_history == 'last') {
+ $send_previous_messages = true;
+ $this->threads = $this->threads->slice(0, 2);
+ }
+
+ if ($email_conv_history == 'none') {
+ $send_previous_messages = false;
+ }
+
+ if (!$is_forward) {
+ $send_previous_messages = \Eventy::filter('jobs.send_reply_to_customer.send_previous_messages', $send_previous_messages, $this->last_thread, $this->threads, $this->conversation, $this->customer);
+ }
+
+ // Remove previous messages.
+ if (!$send_previous_messages) {
+ $this->threads = $this->threads->slice(0, 1);
+ }
+
+ // Configure mail driver according to Mailbox settings
+ \MailHelper::setMailDriver($mailbox, $this->last_thread->created_by_user, $this->conversation);
+
+ // https://github.com/freescout-helpdesk/freescout/issues/3330
+ if (!\MailHelper::$smtp_queue_id_plugin_registered) {
+ \Mail::getSwiftMailer()->registerPlugin(new SwiftGetSmtpQueueId());
+ \MailHelper::$smtp_queue_id_plugin_registered = true;
+ }
+
+ $this->message_id = $this->last_thread->getMessageId($mailbox);
+ $headers['Message-ID'] = $this->message_id;
+
+ $this->customer_email = $this->conversation->customer_email;
+
+ // For phone conversations we may need to get customer email.
+ // https://github.com/freescout-helpdesk/freescout/issues/3270
+ if (!$this->customer_email && $this->conversation->isPhone()) {
+ $this->customer_email = $this->conversation->customer->getMainEmail();
+ if (!$this->customer_email) {
+ return;
+ }
+ }
+
+ $to_array = $mailbox->removeMailboxEmailsFromList($this->last_thread->getToArray());
+ $cc_array = $mailbox->removeMailboxEmailsFromList($this->last_thread->getCcArray());
+ $bcc_array = $mailbox->removeMailboxEmailsFromList($this->last_thread->getBccArray());
+
+ // Remove customer email from CC and BCC
+ $cc_array = \App\Misc\Mail::removeEmailFromArray($cc_array, $this->customer_email);
+ $bcc_array = \App\Misc\Mail::removeEmailFromArray($bcc_array, $this->customer_email);
+
+ // Auto Bcc.
+ if ($mailbox->auto_bcc) {
+ $auto_bcc = \MailHelper::sanitizeEmails($mailbox->auto_bcc);
+ if ($auto_bcc) {
+ $bcc_array = array_merge($bcc_array, $auto_bcc);
+ }
+ }
+
+ // Remove from BCC emails which are present in CC
+ foreach ($cc_array as $cc_email) {
+ $bcc_array = \App\Misc\Mail::removeEmailFromArray($bcc_array, $cc_email);
+ }
+
+ $this->recipients = array_merge($to_array, $cc_array, $bcc_array);
+
+ $to = [];
+ if (count($to_array) > 1) {
+ $to = $to_array;
+ } else {
+ $to = [['name' => $this->customer->getFullName(), 'email' => $this->customer_email]];
+ }
+
+ // If sending fails, all recipiens fail.
+ // if ($this->attempts() > 1) {
+ // $cc_array = [];
+ // $bcc_array = [];
+ // }
+
+ $subject = $this->conversation->subject;
+ if (!$new && !$is_forward) {
+ $subject = 'Re: '.$subject;
+ }
+ $subject = \Eventy::filter('email.reply_to_customer.subject', $subject, $this->conversation, $this->last_thread);
+ $this->threads = \Eventy::filter('email.reply_to_customer.threads', $this->threads, $this->conversation, $mailbox);
+
+ $headers['X-FreeScout-Mail-Type'] = 'customer.message';
+
+ $reply_mail = new ReplyToCustomer($this->conversation, $this->threads, $headers, $mailbox, $subject, $threads_count);
+
+ $smtp_queue_id = null;
+
+ try {
+ Mail::to($to)
+ ->cc($cc_array)
+ ->bcc($bcc_array)
+ ->send($reply_mail);
+
+ $smtp_queue_id = SwiftGetSmtpQueueId::$last_smtp_queue_id;
+ } catch (\Exception $e) {
+ // We come here in case SMTP server unavailable for example
+ if ($this->attempts() == 1) {
+ activity()
+ ->causedBy($this->customer)
+ ->withProperties([
+ 'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
+ ])
+ ->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
+ ->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
+ }
+
+ // Failures will be saved to send log when retry attempts will finish
+ // Mail::failures() is empty in case of connection error.
+ $this->failures = $this->recipients;
+
+ // Save to send log (only first attempt).
+ if ($this->attempts() == 1) {
+ $this->saveToSendLog($e->getMessage());
+ }
+
+ $error_message = $e->getMessage();
+
+ // Remove stack trace from error message.
+ $error_message = preg_replace('#[\r\n]*" in /.*#s', '"', $error_message);
+
+ // SMTP response code is stored in the exception message:
+ // Expected response code 235 but got code "535", with message...
+ preg_match('#but got code "(\d+)",#', $error_message, $response_m);
+ $response_code = (int)($response_m[1] ?? 0);
+
+ // Retry job with delay.
+ // https://stackoverflow.com/questions/35258175/how-can-i-create-delays-between-failed-queued-job-attempts-in-laravel
+ if ($this->attempts() < $this->tries && !preg_match("/".config("app.no_retry_mail_errors")."/i", $error_message)) {
+ if ($this->attempts() == 1) {
+ // Second attempt after 5 min.
+ $this->release(300);
+ } else {
+ // Others - after 1 hour.
+ $this->release(3600);
+ }
+
+ // If an email has not been sent after 1 hour - show an error message to support agent.
+ if ($this->attempts() >= 3 || $response_code >= 500) {
+ $this->last_thread->send_status = SendLog::STATUS_SEND_INTERMEDIATE_ERROR;
+ $this->last_thread->updateSendStatusData(['msg' => $error_message]);
+ $this->last_thread->save();
+ }
+
+ throw $e;
+ } else {
+ $this->last_thread->send_status = SendLog::STATUS_SEND_ERROR;
+ $this->last_thread->updateSendStatusData(['msg' => $error_message]);
+ $this->last_thread->save();
+
+ // This executes $this->failed().
+ $this->fail($e);
+
+ return;
+ }
+ }
+
+ SwiftGetSmtpQueueId::$last_smtp_queue_id = null;
+
+ // Clean error message if email finally has been sent.
+ if ($this->last_thread->send_status == SendLog::STATUS_SEND_ERROR) {
+ $this->last_thread->send_status = null;
+ $this->last_thread->updateSendStatusData(['msg' => '']);
+ $this->last_thread->save();
+ }
+
+ $imap_sent_folder = $mailbox->imap_sent_folder;
+ if ($imap_sent_folder) {
+ try {
+ $client = \MailHelper::getMailboxClient($mailbox);
+
+ $client->connect();
+
+ $envelope['from'] = $mailbox->getMailFrom(null, $this->conversation)['address'];
+ $envelope['to'] = $this->customer_email;
+ $envelope['subject'] = $subject;
+ $envelope['date'] = now()->toRfc2822String();
+ $envelope['message_id'] = $this->message_id;
+
+ // CC.
+ if (count($cc_array)) {
+ $envelope['cc'] = implode(',', $cc_array);
+ }
+
+ // Get penultimate email Message-Id if reply
+ if (!$new && !empty($last_customer_thread) && $last_customer_thread->message_id) {
+ $envelope['custom_headers'] = [
+ 'In-Reply-To: <'.$last_customer_thread->message_id.'>',
+ 'References: '.$references,
+ ];
+ }
+ // Remove new lines to avoid "imap_mail_compose(): header injection attempt in subject".
+ foreach ($envelope as $i => $envelope_value) {
+ $envelope[$i] = preg_replace("/[\r\n]/", '', $envelope_value);
+ }
+
+ $parts = [];
+
+ // Multipart flag.
+ if ($this->last_thread->has_attachments) {
+ $multipart = [];
+ $multipart["type"] = TYPEMULTIPART;
+ $multipart["subtype"] = "alternative";
+ $parts[] = $multipart;
+ }
+
+ // Body.
+ $part_body['type'] = TYPETEXT;
+ $part_body['subtype'] = 'html';
+ $part_body['contents.data'] = $reply_mail->render();
+ $part_body['charset'] = 'utf-8';
+
+ $parts[] = $part_body;
+
+ // Add attachments.
+ if ($this->last_thread->has_attachments) {
+
+ foreach ($this->last_thread->attachments as $attachment) {
+
+ if ($attachment->embedded) {
+ continue;
+ }
+
+ if ($attachment->fileExists()) {
+ $part = [];
+ $part["type"] = 'APPLICATION';
+ $part["encoding"] = ENCBASE64;
+ $part["subtype"] = "octet-stream";
+ $part["description"] = $attachment->file_name;
+ $part['disposition.type'] = 'attachment';
+ $part['disposition'] = array('filename' => $attachment->file_name);
+ $part['type.parameters'] = array('name' => $attachment->file_name);
+ $part["description"] = '';
+ $part["contents.data"] = base64_encode($attachment->getFileContents());
+
+ $parts[] = $part;
+ } else {
+ \Log::error('[IMAP Folder To Save Outgoing Replies] Thread: '.$this->last_thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
+ }
+ }
+ }
+
+ try {
+ // https://github.com/freescout-helpdesk/freescout/issues/3502
+ $imap_sent_folder = mb_convert_encoding($imap_sent_folder, "UTF7-IMAP","UTF-8");
+
+ // https://github.com/Webklex/php-imap/issues/380
+ if (method_exists($client, 'getFolderByPath')) {
+ $folder = $client->getFolderByPath($imap_sent_folder);
+ } else {
+ $folder = $client->getFolder($imap_sent_folder);
+ }
+ // Get folder method does not work if sent folder has spaces.
+ if ($folder) {
+ try {
+ $save_result = $this->saveEmailToFolder($client, $folder, $envelope, $parts, $bcc_array);
+
+ // Sometimes emails with attachments by some reason are not saved.
+ // https://github.com/freescout-helpdesk/freescout/issues/2749
+ if (!$save_result) {
+ // Save without attachments.
+ $save_result = $this->saveEmailToFolder($client, $folder, $envelope, [$part_body], $bcc_array);
+ if (!$save_result) {
+ \Log::error($this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder (check folder name and make sure IMAP folder does not have spaces - folders with spaces do not work): '.$imap_sent_folder);
+ }
+ }
+ } catch (\Exception $e) {
+ // Just log error and continue.
+ \Helper::logException($e, $this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder: ');
+ }
+ } else {
+ \Log::error($this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder (check folder name and make sure IMAP folder does not have spaces - folders with spaces do not work): '.$imap_sent_folder);
+ }
+ } catch (\Exception $e) {
+ // Just log error and continue.
+ \Helper::logException($e, $this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder, IMAP folder not found: '.$imap_sent_folder.' - ');
+ //$this->saveToSendLog('['.date('Y-m-d H:i:s').'] Could not save outgoing reply to the IMAP folder: '.$imap_sent_folder);
+ }
+ } catch (\Exception $e) {
+ // Just log error and continue.
+ //$this->saveToSendLog('['.date('Y-m-d H:i:s').'] Could not get mailbox IMAP folder: '.$imap_sent_folder);
+ \Helper::logException($e, $this->getImapSaveErrorPrefix($mailbox).'Could not save outgoing reply to the IMAP folder: '.$imap_sent_folder.' - ');
+ }
+ }
+
+ // In message_id we are storing Message-ID of the incoming email which created the thread
+ // Outcoming message_id can be generated for each thread by thread->id
+ // $this->last_thread->message_id = $message_id;
+ // $this->last_thread->save();
+
+ // Laravel tells us exactly what email addresses failed
+ $this->failures = Mail::failures();
+
+ // Save to send log
+ $this->saveToSendLog('', $smtp_queue_id);
+ }
+
+ public function getImapSaveErrorPrefix($mailbox)
+ {
+ return '['.$mailbox->name.' » Connection Settings » Fetching Emails » IMAP Folder To Save Outgoing Replies] ';
+ }
+
+ // Save an email to IMAP folder.
+ public function saveEmailToFolder($client, $folder, $envelope, $parts, $bcc = [])
+ {
+ $envelope_str = imap_mail_compose($envelope, $parts);
+
+ // Add BCC.
+ // https://stackoverflow.com/questions/47353938/php-imap-append-with-bcc
+ if (!empty($bcc)) {
+ // There will be a "To:" parameter for sure.
+ $to_pos = strpos($envelope_str , "To:");
+ if ($to_pos !== false) {
+ $bcc_str = "Bcc: " . implode(',', $bcc) . "\r\n";
+ $envelope_str = substr_replace($envelope_str , $bcc_str, $to_pos, 0);
+ }
+ }
+
+ if (get_class($client) == 'Webklex\PHPIMAP\Client') {
+ return $folder->appendMessage($envelope_str, ['\Seen'], now()->format('d-M-Y H:i:s O'));
+ } else {
+ return $folder->appendMessage($envelope_str, '\Seen', now()->format('d-M-Y H:i:s O'));
+ }
+ }
+
+ /**
+ * The job failed to process.
+ * This method is called after attempts had finished.
+ * At this stage method has access only to variables passed in constructor.
+ *
+ * @param Exception $exception
+ *
+ * @return void
+ */
+ public function failed(\Exception $e)
+ {
+ activity()
+ ->causedBy($this->customer)
+ ->withProperties([
+ 'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
+ 'to' => $this->customer_email,
+ ])
+ ->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
+ ->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
+
+ $this->saveToSendLog();
+ }
+
+ /**
+ * Save emails to send log.
+ */
+ public function saveToSendLog($error_message = '', $smtp_queue_id = '')
+ {
+ foreach ($this->recipients as $recipient) {
+ if (in_array($recipient, $this->failures)) {
+ $status = SendLog::STATUS_SEND_ERROR;
+ $status_message = $error_message;
+ } else {
+ $status = SendLog::STATUS_ACCEPTED;
+ $status_message = '';
+ }
+ if ($this->customer_email == $recipient) {
+ $customer_id = $this->customer->id;
+ } else {
+ $customer_id = null;
+ }
+ SendLog::log($this->last_thread->id, $this->message_id, $recipient, SendLog::MAIL_TYPE_EMAIL_TO_CUSTOMER, $status, $customer_id, null, $status_message, $smtp_queue_id);
+ }
+ }
+}
diff --git a/freescout-dist/app/Jobs/TriggerAction.php b/freescout-dist/app/Jobs/TriggerAction.php
new file mode 100644
index 0000000..92bee7c
--- /dev/null
+++ b/freescout-dist/app/Jobs/TriggerAction.php
@@ -0,0 +1,44 @@
+action = $action;
+ $this->params = $params;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $args = $this->params;
+ array_unshift($args, $this->action);
+
+ call_user_func_array("\Eventy::action", $args);
+ }
+}
diff --git a/freescout-dist/app/Jobs/UpdateFolderCounters.php b/freescout-dist/app/Jobs/UpdateFolderCounters.php
new file mode 100644
index 0000000..a012dbd
--- /dev/null
+++ b/freescout-dist/app/Jobs/UpdateFolderCounters.php
@@ -0,0 +1,42 @@
+folder = $folder;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $this->folder->updateCountersNow();
+ }
+}
diff --git a/freescout-dist/app/Listeners/ActivateUser.php b/freescout-dist/app/Listeners/ActivateUser.php
new file mode 100644
index 0000000..5d1c002
--- /dev/null
+++ b/freescout-dist/app/Listeners/ActivateUser.php
@@ -0,0 +1,35 @@
+user->invite_state != User::INVITE_STATE_ACTIVATED) {
+ $event->user->invite_state = User::INVITE_STATE_ACTIVATED;
+ $event->user->invite_hash = '';
+ $event->user->save();
+ }
+ }
+}
diff --git a/freescout-dist/app/Listeners/LogFailedLogin.php b/freescout-dist/app/Listeners/LogFailedLogin.php
new file mode 100644
index 0000000..9057fbc
--- /dev/null
+++ b/freescout-dist/app/Listeners/LogFailedLogin.php
@@ -0,0 +1,34 @@
+causedBy($event->user)
+ ->withProperties(['ip' => app('request')->ip(), 'email' => request()->email])
+ ->useLog(\App\ActivityLog::NAME_USER)
+ ->log(\App\ActivityLog::DESCRIPTION_USER_LOGIN_FAILED);
+ }
+}
diff --git a/freescout-dist/app/Listeners/LogLockout.php b/freescout-dist/app/Listeners/LogLockout.php
new file mode 100644
index 0000000..32be985
--- /dev/null
+++ b/freescout-dist/app/Listeners/LogLockout.php
@@ -0,0 +1,34 @@
+causedBy($event->user)
+ ->withProperties(['ip' => app('request')->ip(), 'email' => $event->request->email])
+ ->useLog(\App\ActivityLog::NAME_USER)
+ ->log(\App\ActivityLog::DESCRIPTION_USER_LOCKED);
+ }
+}
diff --git a/freescout-dist/app/Listeners/LogPasswordReset.php b/freescout-dist/app/Listeners/LogPasswordReset.php
new file mode 100644
index 0000000..9cb72c2
--- /dev/null
+++ b/freescout-dist/app/Listeners/LogPasswordReset.php
@@ -0,0 +1,34 @@
+causedBy($event->user)
+ ->withProperties(['ip' => app('request')->ip()])
+ ->useLog(\App\ActivityLog::NAME_USER)
+ ->log(\App\ActivityLog::DESCRIPTION_USER_PASSWORD_RESET);
+ }
+}
diff --git a/freescout-dist/app/Listeners/LogRegisteredUser.php b/freescout-dist/app/Listeners/LogRegisteredUser.php
new file mode 100644
index 0000000..253a7dd
--- /dev/null
+++ b/freescout-dist/app/Listeners/LogRegisteredUser.php
@@ -0,0 +1,34 @@
+causedBy($event->user)
+ ->withProperties(['ip' => app('request')->ip()])
+ ->useLog(\App\ActivityLog::NAME_USER)
+ ->log(\App\ActivityLog::DESCRIPTION_USER_REGISTER);
+ }
+}
diff --git a/freescout-dist/app/Listeners/LogSuccessfulLogin.php b/freescout-dist/app/Listeners/LogSuccessfulLogin.php
new file mode 100644
index 0000000..f1e8cd5
--- /dev/null
+++ b/freescout-dist/app/Listeners/LogSuccessfulLogin.php
@@ -0,0 +1,34 @@
+causedBy($event->user)
+ ->withProperties(['ip' => app('request')->ip()])
+ ->useLog(\App\ActivityLog::NAME_USER)
+ ->log(\App\ActivityLog::DESCRIPTION_USER_LOGIN);
+ }
+}
diff --git a/freescout-dist/app/Listeners/LogSuccessfulLogout.php b/freescout-dist/app/Listeners/LogSuccessfulLogout.php
new file mode 100644
index 0000000..7672d66
--- /dev/null
+++ b/freescout-dist/app/Listeners/LogSuccessfulLogout.php
@@ -0,0 +1,34 @@
+causedBy($event->user)
+ ->withProperties(['ip' => app('request')->ip()])
+ ->useLog(\App\ActivityLog::NAME_USER)
+ ->log(\App\ActivityLog::DESCRIPTION_USER_LOGOUT);
+ }
+}
diff --git a/freescout-dist/app/Listeners/LogUserDeletion.php b/freescout-dist/app/Listeners/LogUserDeletion.php
new file mode 100644
index 0000000..9cee7b4
--- /dev/null
+++ b/freescout-dist/app/Listeners/LogUserDeletion.php
@@ -0,0 +1,34 @@
+causedBy($event->by_user)
+ ->withProperties(['deleted_user' => $event->deleted_user->getFullName().' ['.$event->deleted_user->id.']'])
+ ->useLog(\App\ActivityLog::NAME_USER)
+ ->log(\App\ActivityLog::DESCRIPTION_USER_DELETED);
+ }
+}
diff --git a/freescout-dist/app/Listeners/ProcessSwiftMessage.php b/freescout-dist/app/Listeners/ProcessSwiftMessage.php
new file mode 100644
index 0000000..459a31b
--- /dev/null
+++ b/freescout-dist/app/Listeners/ProcessSwiftMessage.php
@@ -0,0 +1,25 @@
+message);
+ }
+}
diff --git a/freescout-dist/app/Listeners/RefreshConversations.php b/freescout-dist/app/Listeners/RefreshConversations.php
new file mode 100644
index 0000000..3297967
--- /dev/null
+++ b/freescout-dist/app/Listeners/RefreshConversations.php
@@ -0,0 +1,30 @@
+conversation, $event->thread ?? $event->last_thread);
+ }
+}
diff --git a/freescout-dist/app/Listeners/RememberUserLocale.php b/freescout-dist/app/Listeners/RememberUserLocale.php
new file mode 100644
index 0000000..944aa79
--- /dev/null
+++ b/freescout-dist/app/Listeners/RememberUserLocale.php
@@ -0,0 +1,31 @@
+put('user_locale', $event->user->getLocale());
+ }
+}
diff --git a/freescout-dist/app/Listeners/RestartSwiftMailer.php b/freescout-dist/app/Listeners/RestartSwiftMailer.php
new file mode 100644
index 0000000..0277bf7
--- /dev/null
+++ b/freescout-dist/app/Listeners/RestartSwiftMailer.php
@@ -0,0 +1,30 @@
+conversation;
+
+ // no_autoreply meta value is checked in the SendAutoReply job.
+
+ if (!$conversation->imported
+ && $conversation->mailbox->auto_reply_enabled
+ ) {
+ $thread = $conversation->threads()->first();
+
+ // Do not send auto reply to auto responders.
+ if ($thread->isAutoResponder()) {
+ return;
+ }
+ // Do not send auto replies to bounces.
+ if ($thread->isBounce()) {
+ return;
+ }
+
+ // Do not send auto replies to spam messages.
+ if ($conversation->status == Conversation::STATUS_SPAM) {
+ return;
+ }
+
+ if (!\Eventy::filter('autoreply.should_send', true, $conversation)) {
+ return;
+ }
+
+ // We can not send auto reply to incoming bounce messages, as it will lead to the infinite loop:
+ // application will be sending auto replies and mail server will be sending bounce messages to auto replies.
+ // Bounce detection can not be 100% reliable.
+ // So to prevent infinite loop, we are checking number of auto replies sent to the customer recently.
+ $created_at = \Illuminate\Support\Carbon::now()->subMinutes(self::CHECK_PERIOD);
+
+ $auto_replies_sent = SendLog::where('customer_id', $conversation->customer_id)
+ ->where('mail_type', SendLog::MAIL_TYPE_AUTO_REPLY)
+ ->where('created_at', '>', $created_at)
+ ->count();
+
+ if ($auto_replies_sent >= 10) {
+ return;
+ }
+
+ if ($auto_replies_sent >= 2) {
+ // Find conversations from this customer
+ $prev_conversations = Conversation::select('subject', 'id')
+ ->where('customer_id', $conversation->customer_id)
+ ->where('created_at', '>', $created_at)
+ ->get();
+
+ foreach ($prev_conversations as $prev_conv) {
+ if ($prev_conv->subject == $conversation->subject && $prev_conv->id != $conversation->id) {
+ return;
+ }
+ }
+ }
+
+ // Do not send autoreplies to own mailboxes.
+ if ($conversation->customer_email) {
+ $is_internal_email = Mailbox::where('email', $conversation->customer_email)->exists();
+ if ($is_internal_email) {
+ return;
+ }
+ }
+
+ // 24h limit has been disabled: https://github.com/freescout-helpdesk/freescout/pull/95
+ // Send auto reply once in 24h
+ /*$created_at = \Illuminate\Support\Carbon::now()->subDays(1);
+ $auto_reply_sent = SendLog::where('customer_id', $conversation->customer_id)
+ ->where('mail_type', SendLog::MAIL_TYPE_AUTO_REPLY)
+ ->where('created_at', '>', $created_at)
+ ->first();
+
+ if ($auto_reply_sent) {
+ return;
+ }*/
+
+ \App\Jobs\SendAutoReply::dispatch($conversation, $thread, $conversation->mailbox, $conversation->customer)
+ ->onQueue('emails');
+ }
+ }
+}
diff --git a/freescout-dist/app/Listeners/SendNotificationToUsers.php b/freescout-dist/app/Listeners/SendNotificationToUsers.php
new file mode 100644
index 0000000..454a7bf
--- /dev/null
+++ b/freescout-dist/app/Listeners/SendNotificationToUsers.php
@@ -0,0 +1,75 @@
+thread->created_by_user_id;
+ $event_type = Subscription::EVENT_TYPE_USER_REPLIED;
+ break;
+ case 'App\Events\UserAddedNote':
+ $caused_by_user_id = $event->thread->created_by_user_id;
+ // When conversation is forwarded only notification
+ // about child forward conversation is sent.
+ if (!$event->thread->isForward()) {
+ $event_type = Subscription::EVENT_TYPE_USER_ADDED_NOTE;
+ }
+ break;
+ case 'App\Events\UserCreatedConversation':
+ $caused_by_user_id = $event->conversation->created_by_user_id;
+ $event_type = Subscription::EVENT_TYPE_NEW;
+ break;
+ case 'App\Events\CustomerCreatedConversation':
+ // Do not send notification if conversation is spam.
+ if ($event->conversation->status != Conversation::STATUS_SPAM) {
+ $event_type = Subscription::EVENT_TYPE_NEW;
+ }
+ break;
+ case 'App\Events\ConversationUserChanged':
+ $caused_by_user_id = $event->user->id;
+ $event_type = Subscription::EVENT_TYPE_ASSIGNED;
+ break;
+ case 'App\Events\CustomerReplied':
+ $event_type = Subscription::EVENT_TYPE_CUSTOMER_REPLIED;
+ break;
+ }
+ if (empty($event->conversation) || !$event_type) {
+ return;
+ }
+
+ // Ignore imported threads.
+ if (!empty($event->thread) && $event->thread->imported) {
+ return;
+ }
+ $conversation = $event->conversation;
+
+ // Using the last argument you can make event to be processed immediately
+ Subscription::registerEvent($event_type, $conversation, $caused_by_user_id/*, true*/);
+ }
+}
diff --git a/freescout-dist/app/Listeners/SendPasswordChanged.php b/freescout-dist/app/Listeners/SendPasswordChanged.php
new file mode 100644
index 0000000..44ab7c9
--- /dev/null
+++ b/freescout-dist/app/Listeners/SendPasswordChanged.php
@@ -0,0 +1,30 @@
+user->sendPasswordChanged();
+ }
+}
diff --git a/freescout-dist/app/Listeners/SendReplyToCustomer.php b/freescout-dist/app/Listeners/SendReplyToCustomer.php
new file mode 100644
index 0000000..306e890
--- /dev/null
+++ b/freescout-dist/app/Listeners/SendReplyToCustomer.php
@@ -0,0 +1,67 @@
+conversation;
+
+ // Do not send email if this is a Phone conversation and customer has no email.
+ if ($conversation->isPhone()) {
+ if (!$conversation->customer->getMainEmail()) {
+ return;
+ }
+ }
+
+ $replies = $conversation->getReplies();
+
+ // Ignore imported messages.
+ if ($replies && $replies->first() && $replies->first()->imported) {
+ return;
+ }
+
+ // Remove threads added after this event had fired.
+ $thread = $event->last_thread ?? $event->thread ?? null;
+ if ($thread) {
+ foreach ($replies as $i => $reply) {
+ if ($reply->id == $thread->id) {
+ break;
+ } else {
+ $replies->forget($i);
+ }
+ }
+ }
+
+ // Chat conversation.
+ if ($conversation->isChat()) {
+ \Helper::backgroundAction('chat_conversation.send_reply', [$conversation, $replies, $conversation->customer], now()->addSeconds(Conversation::UNDO_TIMOUT));
+ return;
+ }
+
+ // We can not check imported here, as after conversation has been imported via API
+ // notifications has to be sent.
+ //if (!$conversation->imported) {
+ $delay = \Eventy::filter('conversation.send_reply_to_customer_delay', now()->addSeconds(Conversation::UNDO_TIMOUT), $conversation, $replies);
+
+ \App\Jobs\SendReplyToCustomer::dispatch($conversation, $replies, $conversation->customer)
+ ->delay($delay)
+ ->onQueue('emails');
+ }
+}
diff --git a/freescout-dist/app/Listeners/UpdateMailboxCounters.php b/freescout-dist/app/Listeners/UpdateMailboxCounters.php
new file mode 100644
index 0000000..481aa44
--- /dev/null
+++ b/freescout-dist/app/Listeners/UpdateMailboxCounters.php
@@ -0,0 +1,28 @@
+conversation->mailbox->updateFoldersCounters();
+ }
+}
diff --git a/freescout-dist/app/Mail/Alert.php b/freescout-dist/app/Mail/Alert.php
new file mode 100644
index 0000000..6e34a94
--- /dev/null
+++ b/freescout-dist/app/Mail/Alert.php
@@ -0,0 +1,50 @@
+text = $text;
+ $this->title = $title;
+ }
+
+ /**
+ * Build the message.
+ *
+ * @return $this
+ */
+ public function build()
+ {
+ \MailHelper::prepareMailable($this);
+
+ $subject = '['.\Config::get('app.name').'] ';
+ if (!empty($this->title)) {
+ $subject .= $this->title;
+ } else {
+ // System emails are not translated
+ $subject .= 'Alert';
+ }
+ $subject .= ' - '.\Helper::getDomain();
+ $message = $this->subject($subject)
+ ->view('emails/user/alert', ['text' => $this->text, 'title' => $this->title]);
+
+ return $message;
+ }
+}
diff --git a/freescout-dist/app/Mail/AutoReply.php b/freescout-dist/app/Mail/AutoReply.php
new file mode 100644
index 0000000..cbda1ca
--- /dev/null
+++ b/freescout-dist/app/Mail/AutoReply.php
@@ -0,0 +1,96 @@
+conversation = $conversation;
+ $this->mailbox = $mailbox;
+ $this->customer = $customer;
+ $this->headers = $headers;
+ }
+
+ /**
+ * Build the message.
+ *
+ * @return $this
+ */
+ public function build()
+ {
+ \MailHelper::prepareMailable($this);
+
+ $view_params = [];
+
+ // Set headers
+ $this->setHeaders();
+
+ $data = [
+ 'mailbox' => $this->mailbox,
+ 'conversation' => $this->conversation,
+ 'customer' => $this->customer,
+ ];
+
+ // Set variables
+ $subject = \MailHelper::replaceMailVars($this->mailbox->auto_reply_subject, $data);
+ $view_params['auto_reply_message'] = \MailHelper::replaceMailVars($this->mailbox->auto_reply_message, $data);
+
+ $subject = \Eventy::filter('email.auto_reply.subject', $subject, $this->conversation);
+
+ $message = $this->subject($subject)
+ ->view('emails/customer/auto_reply', $view_params)
+ ->text('emails/customer/auto_reply_text', $view_params);
+
+ return $message;
+ }
+
+ /**
+ * Set headers.
+ * Settings via $this->addCustomHeaders does not work.
+ */
+ public function setHeaders()
+ {
+ $new_headers = $this->headers;
+ if (!empty($new_headers)) {
+ $this->withSwiftMessage(function ($swiftmessage) use ($new_headers) {
+ if (!empty($new_headers['Message-ID'])) {
+ $swiftmessage->setId($new_headers['Message-ID']);
+ }
+ $headers = $swiftmessage->getHeaders();
+ foreach ($new_headers as $header => $value) {
+ if ($header != 'Message-ID') {
+ $headers->addTextHeader($header, $value);
+ }
+ }
+
+ return $swiftmessage;
+ });
+ }
+ }
+}
diff --git a/freescout-dist/app/Mail/PasswordChanged.php b/freescout-dist/app/Mail/PasswordChanged.php
new file mode 100644
index 0000000..befa836
--- /dev/null
+++ b/freescout-dist/app/Mail/PasswordChanged.php
@@ -0,0 +1,37 @@
+user = $user;
+ }
+
+ /**
+ * Build the message.
+ *
+ * @return $this
+ */
+ public function build()
+ {
+ \MailHelper::prepareMailable($this);
+
+ $message = $this->subject(__('Password Changed'))
+ ->view('emails/user/password_changed')
+ ->text('emails/user/password_changed_text');
+
+ return $message;
+ }
+}
diff --git a/freescout-dist/app/Mail/ReplyToCustomer.php b/freescout-dist/app/Mail/ReplyToCustomer.php
new file mode 100644
index 0000000..1850a8f
--- /dev/null
+++ b/freescout-dist/app/Mail/ReplyToCustomer.php
@@ -0,0 +1,188 @@
+conversation = $conversation;
+ $this->threads = $threads;
+ $this->headers = $headers;
+ $this->mailbox = $mailbox;
+ $this->subject = $subject;
+ $this->threads_count = $threads_count;
+ }
+
+ /**
+ * Build the message.
+ *
+ * @return $this
+ */
+ public function build()
+ {
+ \MailHelper::prepareMailable($this);
+
+ $thread = $this->threads->first();
+ $from_alias = trim($thread->from ?? '');
+
+ // Set Message-ID
+ // Settings via $this->addCustomHeaders does not work
+ $new_headers = $this->headers;
+ if (!empty($new_headers) || $from_alias) {
+ $mailbox = $this->mailbox;
+ $this->withSwiftMessage(function ($swiftmessage) use ($new_headers, $from_alias, $mailbox, $thread) {
+ if (!empty($new_headers)) {
+ if (!empty($new_headers['Message-ID'])) {
+ $swiftmessage->setId($new_headers['Message-ID']);
+ }
+ $headers = $swiftmessage->getHeaders();
+ foreach ($new_headers as $header => $value) {
+ if ($header != 'Message-ID') {
+ $headers->addTextHeader($header, $value);
+ }
+ }
+ }
+ if (!empty($from_alias)) {
+ $aliases = $mailbox->getAliases();
+
+ // Make sure that the From contains a mailbox alias,
+ // as user thread may have From specified when a user
+ // replies to an email notification.
+ if (array_key_exists($from_alias, $aliases)) {
+
+ $from_alias_name = $aliases[$from_alias] ?? '';
+
+ // Take into account mailbox From Name setting.
+ $mailbox_mail_from = $mailbox->getMailFrom($thread->created_by_user, $thread->conversation);
+ if ($mailbox_mail_from['name'] == $mailbox->name && $from_alias_name) {
+ // Use name from alias.
+ } else {
+ // User name or custom.
+ $from_alias_name = $mailbox_mail_from['name'];
+ }
+
+ $swift_from = $headers->get('From');
+
+ if ($from_alias_name) {
+ $swift_from->setNameAddresses([
+ $from_alias => $from_alias_name
+ ]);
+ } else {
+ $swift_from->setAddresses([
+ $from_alias
+ ]);
+ }
+ }
+ }
+
+ return $swiftmessage;
+ });
+ }
+
+ // from($this->from) Sets only email, name stays empty.
+ // So we set from in Mail::setMailDriver
+ $message = $this->subject($this->subject)
+ ->view('emails/customer/reply_fancy')
+ ->text('emails/customer/reply_fancy_text');
+
+ if ($thread->has_attachments) {
+ foreach ($thread->attachments as $attachment) {
+ if ($attachment->fileExists()) {
+ $message->attach($attachment->getLocalFilePath());
+ } else {
+ \Log::error('[ReplyToCustomer] Thread: '.$thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
+ }
+ }
+ }
+
+ return $message;
+ }
+
+ /*
+ * Send the message using the given mailer.
+ *
+ * @param \Illuminate\Contracts\Mail\Mailer $mailer
+ * @return void
+ */
+ // public function send(MailerContract $mailer)
+ // {
+ // Container::getInstance()->call([$this, 'build']);
+
+ // $mailer->send($this->buildView(), $this->buildViewData(), function ($message) {
+ // $this->buildFrom($message)
+ // ->buildRecipients($message)
+ // ->buildSubject($message)
+ // ->buildAttachments($message)
+ // ->addCustomHeaders($message) // This is new!
+ // ->runCallbacks($message);
+ // });
+ // }
+
+ /*
+ * Add custom headers to the message.
+ *
+ * @param \Illuminate\Mail\Message $message
+ * @return $this
+ */
+ // protected function addCustomHeaders($message)
+ // {
+ // $swift = $message->getSwiftMessage();
+ // $headers = $swift->getHeaders();
+
+ // // By some reason $this->headers are empty here
+ // foreach ($this->headers as $header => $value) {
+ // $headers->addTextHeader($header, $value);
+ // }
+ // return $this;
+ // }
+}
diff --git a/freescout-dist/app/Mail/Test.php b/freescout-dist/app/Mail/Test.php
new file mode 100644
index 0000000..2c20ce1
--- /dev/null
+++ b/freescout-dist/app/Mail/Test.php
@@ -0,0 +1,44 @@
+mailbox = $mailbox;
+ }
+
+ /**
+ * Build the message.
+ *
+ * @return $this
+ */
+ public function build()
+ {
+ \MailHelper::prepareMailable($this);
+
+ $this->withSwiftMessage(function ($swiftmessage) {
+ $headers = $swiftmessage->getHeaders();
+ $headers->addTextHeader('X-FreeScout-Mail-Type', 'test.mailbox');
+
+ return $swiftmessage;
+ });
+
+ $message = $this->subject(__(':app_name Test Email', ['app_name' => \Config::get('app.name')]));
+ if ($this->mailbox) {
+ $message->view('emails/user/test', ['mailbox' => $this->mailbox]);
+ } else {
+ $message->view('emails/user/test_system');
+ }
+
+ return $message;
+ }
+}
diff --git a/freescout-dist/app/Mail/UserEmailReplyError.php b/freescout-dist/app/Mail/UserEmailReplyError.php
new file mode 100644
index 0000000..0bd4de3
--- /dev/null
+++ b/freescout-dist/app/Mail/UserEmailReplyError.php
@@ -0,0 +1,33 @@
+subject(__('Unable to process your update'))
+ ->view('emails/user/email_reply_error');
+ }
+}
diff --git a/freescout-dist/app/Mail/UserInvite.php b/freescout-dist/app/Mail/UserInvite.php
new file mode 100644
index 0000000..4d5dd54
--- /dev/null
+++ b/freescout-dist/app/Mail/UserInvite.php
@@ -0,0 +1,38 @@
+user = $user;
+ }
+
+ /**
+ * Build the message.
+ *
+ * @return $this
+ */
+ public function build()
+ {
+ \MailHelper::prepareMailable($this);
+
+ $message = $this->subject(__('Welcome to :company_name!', ['company_name' => Option::getCompanyName()]))
+ ->view('emails/user/user_invite')
+ ->text('emails/user/user_invite_text');
+
+ return $message;
+ }
+}
diff --git a/freescout-dist/app/Mail/UserNotification.php b/freescout-dist/app/Mail/UserNotification.php
new file mode 100644
index 0000000..b1e5937
--- /dev/null
+++ b/freescout-dist/app/Mail/UserNotification.php
@@ -0,0 +1,105 @@
+user = $user;
+ $this->conversation = $conversation;
+ $this->threads = $threads;
+ $this->headers = $headers;
+ $this->from = $from;
+ $this->mailbox = $mailbox;
+ }
+
+ /**
+ * Build the message.
+ *
+ * @return $this
+ */
+ public function build()
+ {
+ \MailHelper::prepareMailable($this);
+
+ // Set Message-ID
+ // Settings via $this->addCustomHeaders does not work
+ $new_headers = $this->headers;
+ if (!empty($new_headers)) {
+ $this->withSwiftMessage(function ($swiftmessage) use ($new_headers) {
+ if (!empty($new_headers['Message-ID'])) {
+ $swiftmessage->setId($new_headers['Message-ID']);
+ }
+ $headers = $swiftmessage->getHeaders();
+ foreach ($new_headers as $header => $value) {
+ if ($header != 'Message-ID') {
+ $headers->addTextHeader($header, $value);
+ }
+ }
+
+ return $swiftmessage;
+ });
+ }
+
+ $subject = '[#'.$this->conversation->number.'] '.$this->conversation->subject;
+
+ $customer = $this->conversation->customer;
+
+ $thread = $this->threads->first();
+
+ return $this->subject($subject)
+ ->from($this->from['address'], $this->from['name'])
+ ->view('emails/user/notification', ['customer' => $customer, 'thread' => $thread, 'mailbox' => $this->mailbox])
+ ->text('emails/user/notification_text', ['customer' => $customer, 'thread' => $thread, 'mailbox' => $this->mailbox]);
+ }
+}
diff --git a/freescout-dist/app/Mailbox.php b/freescout-dist/app/Mailbox.php
new file mode 100644
index 0000000..7286737
--- /dev/null
+++ b/freescout-dist/app/Mailbox.php
@@ -0,0 +1,978 @@
+ '',
+ self::OUT_ENCRYPTION_SSL => 'ssl',
+ self::OUT_ENCRYPTION_TLS => 'tls',
+ ];
+
+ /**
+ * Incoming protocol.
+ */
+ const IN_PROTOCOL_IMAP = 1;
+ const IN_PROTOCOL_POP3 = 2;
+
+ public static $in_protocols = [
+ self::IN_PROTOCOL_IMAP => 'imap',
+ self::IN_PROTOCOL_POP3 => 'pop3',
+ ];
+
+ /**
+ * Incoming encryption.
+ */
+ const IN_ENCRYPTION_NONE = 1;
+ const IN_ENCRYPTION_SSL = 2;
+ const IN_ENCRYPTION_TLS = 3;
+
+ public static $in_encryptions = [
+ self::IN_ENCRYPTION_NONE => '',
+ self::IN_ENCRYPTION_SSL => 'ssl',
+ self::IN_ENCRYPTION_TLS => 'tls',
+ ];
+
+ /**
+ * Ratings Playcement: place ratings text above/below signature.
+ */
+ const RATINGS_PLACEMENT_ABOVE = 1;
+ const RATINGS_PLACEMENT_BELOW = 2;
+
+ /**
+ * Access permissions.
+ */
+ const ACCESS_PERM_EDIT = 'edit';
+ const ACCESS_PERM_PERMISSIONS = 'perm';
+ const ACCESS_PERM_AUTO_REPLIES = 'auto';
+ const ACCESS_PERM_SIGNATURE = 'sig';
+ const ACCESS_PERM_ASSIGNED = 'asg';
+
+ public static $access_permissions = [
+ self::ACCESS_PERM_EDIT,
+ self::ACCESS_PERM_PERMISSIONS,
+ self::ACCESS_PERM_AUTO_REPLIES,
+ self::ACCESS_PERM_SIGNATURE,
+ ];
+
+ public static $access_routes = [
+ self::ACCESS_PERM_EDIT => 'mailboxes.update',
+ self::ACCESS_PERM_PERMISSIONS => 'mailboxes.permissions',
+ self::ACCESS_PERM_AUTO_REPLIES => 'mailboxes.auto_reply',
+ self::ACCESS_PERM_SIGNATURE => 'mailboxes.update',
+ ];
+
+ /**
+ * Default signature set when mailbox created.
+ */
+ const DEFAULT_SIGNATURE = '
--
+{%mailbox.name%}';
+
+ /**
+ * Default values.
+ */
+ protected $attributes = [
+ 'signature' => self::DEFAULT_SIGNATURE,
+ ];
+
+ protected $casts = [
+ 'meta' => 'array',
+ ];
+
+ /**
+ * Attributes fillable using fill() method.
+ *
+ * @var [type]
+ */
+ protected $fillable = ['name', 'email', 'aliases', 'aliases_reply', 'auto_bcc', 'from_name', 'from_name_custom', 'ticket_status', 'ticket_assignee', 'template', 'before_reply', 'signature', 'out_method', 'out_server', 'out_username', 'out_password', 'out_port', 'out_encryption', 'in_server', 'in_port', 'in_username', 'in_password', 'in_protocol', 'in_encryption', 'in_validate_cert', 'auto_reply_enabled', 'auto_reply_subject', 'auto_reply_message', 'office_hours_enabled', 'ratings', 'ratings_placement', 'ratings_text', 'imap_sent_folder'];
+
+ protected static function boot()
+ {
+ parent::boot();
+
+ // self::created(function (Mailbox $model) {
+ // $model->slug = strtolower(substr(md5(Hash::make($model->id)), 0, 16));
+ // });
+ }
+
+ /**
+ * Automatically encrypt password on save.
+ */
+ public function setInPasswordAttribute($value)
+ {
+ if ($value != '') {
+ $this->attributes['in_password'] = encrypt($value);
+ } else {
+ $this->attributes['in_password'] = '';
+ }
+ }
+
+ /**
+ * Automatically decrypt password on read.
+ */
+ public function getInPasswordAttribute($value)
+ {
+ if (!$value) {
+ return '';
+ }
+
+ try {
+ return decrypt($value);
+ } catch (\Exception $e) {
+ // do nothing if decrypt wasn't succefull
+ return '';
+ }
+ }
+
+ /**
+ * Automatically encrypt password on save.
+ */
+ public function setOutPasswordAttribute($value)
+ {
+ if ($value != '') {
+ $this->attributes['out_password'] = encrypt($value);
+ } else {
+ $this->attributes['out_password'] = '';
+ }
+ }
+
+ /**
+ * Automatically decrypt password on read.
+ */
+ public function getOutPasswordAttribute($value)
+ {
+ if (!$value) {
+ return '';
+ }
+
+ try {
+ return decrypt($value);
+ } catch (\Exception $e) {
+ // do nothing if decrypt wasn't succefull
+ return '';
+ }
+ }
+
+ /**
+ * Get users having access to the mailbox.
+ */
+ public function users()
+ {
+ return $this->belongsToMany('App\User');
+ }
+
+ public function usersWithSettings()
+ {
+ return $this->belongsToMany('App\User')->as('settings')
+ ->withPivot('after_send')
+ ->withPivot('hide')
+ ->withPivot('mute')
+ ->withPivot('access');
+ }
+
+ /**
+ * Get users having access to the mailbox.
+ */
+ public function users_cached()
+ {
+ return $this->users()->rememberForever();
+ }
+
+ /**
+ * Get mailbox conversations.
+ */
+ public function conversations()
+ {
+ return $this->hasMany('App\Conversation');
+ }
+
+ /**
+ * Get mailbox folders.
+ */
+ public function folders()
+ {
+ return $this->hasMany('App\Folder');
+ }
+
+ /**
+ * Create personal folders for users.
+ *
+ * @param mixed $users
+ */
+ public function syncPersonalFolders($users = null)
+ {
+ if (!empty($users) && is_array($users)) {
+ $user_ids = $users;
+ } else {
+ $user_ids = $this->users()->pluck('users.id')->toArray();
+ }
+
+ // Add admins
+ $admin_user_ids = User::where('role', User::ROLE_ADMIN)->pluck('id')->toArray();
+ $user_ids = array_merge($user_ids, $admin_user_ids);
+
+ self::createUsersFolders($user_ids, $this->id, Folder::$personal_types);
+ }
+
+ /**
+ * Created folders of specific type for passed users.
+ */
+ public static function createUsersFolders($user_ids, $mailbox_id, $folder_types)
+ {
+ $cur_users = Folder::select('user_id')
+ ->where('mailbox_id', $mailbox_id)
+ ->whereIn('user_id', $user_ids)
+ ->groupBy('user_id')
+ ->pluck('user_id')
+ ->toArray();
+
+ foreach ($user_ids as $user_id) {
+ if (in_array($user_id, $cur_users)) {
+ continue;
+ }
+ foreach ($folder_types as $type) {
+ Folder::create([
+ 'mailbox_id' => $mailbox_id,
+ 'user_id' => $user_id,
+ 'type' => $type,
+ ]);
+ }
+ }
+ }
+
+ public function createPublicFolders()
+ {
+ foreach (Folder::$public_types as $type) {
+ $folder = new Folder();
+ $folder->mailbox_id = $this->id;
+ $folder->type = $type;
+ $folder->save();
+ }
+ }
+
+ public function createAdminPersonalFolders()
+ {
+ $user_ids = User::where('role', User::ROLE_ADMIN)->pluck('id')->toArray();
+ self::createUsersFolders($user_ids, $this->id, Folder::$personal_types);
+ }
+
+ public static function createAdminPersonalFoldersAllMailboxes($user_ids = null)
+ {
+ if (empty($user_ids)) {
+ $user_ids = User::where('role', User::ROLE_ADMIN)->pluck('id')->toArray();
+ }
+ $mailbox_ids = self::pluck('id');
+ foreach ($mailbox_ids as $mailbox_id) {
+ self::createUsersFolders($user_ids, $mailbox_id, Folder::$personal_types);
+ }
+ }
+
+ /**
+ * Get folders for the dashboard.
+ */
+ public function getMainFolders()
+ {
+ $main_folders = \Eventy::filter('mailbox.main_folders', [], $this);
+ if ($main_folders) {
+ return $main_folders;
+ }
+
+ return $this->folders()
+ ->where(function ($query) {
+ $query->whereIn('type', [Folder::TYPE_UNASSIGNED, Folder::TYPE_ASSIGNED, Folder::TYPE_DRAFTS])
+ ->orWhere(function ($query2) {
+ $query2->where(['type' => Folder::TYPE_MINE]);
+ $query2->where(['user_id' => auth()->user()->id]);
+ })
+ ->orWhere(function ($query3) {
+ $query3->where(['type' => Folder::TYPE_STARRED]);
+ $query3->where(['user_id' => auth()->user()->id]);
+ });
+ })
+ ->orderBy('type')
+ ->get();
+ }
+
+ /**
+ * Get folder by it's type.
+ */
+ public function getFolderByType($type)
+ {
+ return $this->folders()
+ ->where('type', $type)
+ ->first();
+ }
+
+ /**
+ * Get folders available for the current user.
+ */
+ public function getAssesibleFolders()
+ {
+ $folders = $this->folders()
+ ->where(function ($query) {
+ $query->whereIn('type', \Eventy::filter('mailbox.folders.public_types', Folder::$public_types))
+ ->orWhere(function ($query2) {
+ $query2->whereIn('type', Folder::$personal_types);
+ $query2->where(['user_id' => auth()->user()->id]);
+ });
+ })
+ ->orderBy('type')
+ ->get();
+
+ return \Eventy::filter('mailbox.folders', $folders, $this);
+ }
+
+ /**
+ * Update total and active counters for folders.
+ */
+ public function updateFoldersCounters($folder_type = null)
+ {
+ if (!$folder_type) {
+ $folders = $this->folders;
+ } else {
+ $folders = $this->folders()->where('folders.type', $folder_type)->get();
+ }
+
+ foreach ($folders as $folder) {
+ $folder->updateCounters();
+ }
+ }
+
+ /**
+ * Is mailbox available for using.
+ *
+ * @return bool
+ */
+ public function isActive()
+ {
+ return $this->isInActive() && $this->isOutActive();
+ }
+
+ /**
+ * Is receiving emails configured for the mailbox.
+ *
+ * @return bool
+ */
+ public function isInActive()
+ {
+ $in_active = \Eventy::filter('mailbox.in_active', null, $this);
+
+ if (is_bool($in_active)) {
+ return $in_active;
+ }
+
+ if ($this->in_protocol && $this->in_server && $this->in_port && $this->in_username && $this->in_password) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Is sending emails configured for the mailbox.
+ *
+ * @return bool
+ */
+ public function isOutActive()
+ {
+ if ($this->out_method != self::OUT_METHOD_PHP_MAIL && $this->out_method != self::OUT_METHOD_SENDMAIL
+ && (!$this->out_server /*|| !$this->out_username || !$this->out_password*/)
+ ) {
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Get users who have access to the mailbox.
+ */
+ public function usersHavingAccess($cache = false, $fields = 'users.*', $sort = true)
+ {
+ $admins = User::where('role', User::ROLE_ADMIN)->select($fields)->remember(\Helper::cacheTime($cache))->get();
+
+ $users = $this->users()->select($fields)->remember(\Helper::cacheTime($cache))->get()->merge($admins)->unique();
+
+ // Exclude deleted users (better to do it in PHP).
+ foreach ($users as $i => $user) {
+ if (!$user->isActive()) {
+ $users->forget($i);
+ }
+ }
+
+ // Sort by full name
+ if ($sort) {
+ $users = User::sortUsers($users);
+ }
+
+ $users = \Eventy::filter('mailbox.users_having_access', $users, $this, $cache, $fields, $sort);
+
+ return $users;
+ }
+
+ public function usersAssignable($cache = true, $exclude_hidden = true)
+ {
+ // Exclude hidden admins.
+ $mailbox_id = $this->id;
+ $admins = User::select(['users.*', 'mailbox_user.hide'])
+ ->leftJoin('mailbox_user', function ($join) use ($mailbox_id) {
+ $join->on('mailbox_user.user_id', '=', 'users.id');
+ $join->where('mailbox_user.mailbox_id', $mailbox_id);
+ })
+ ->where('role', User::ROLE_ADMIN)
+ ->remember(\Helper::cacheTime($cache))
+ ->get();
+
+ $users = $this->users()->select(['users.*', 'mailbox_user.hide'])
+ ->remember(\Helper::cacheTime($cache))
+ ->get()
+ ->merge($admins)
+ ->unique();
+
+ if ($exclude_hidden) {
+ foreach ($users as $i => $user) {
+ if (!empty($user->hide)) {
+ $users->forget($i);
+ }
+ }
+ }
+
+ // Exclude deleted users (better to do it in PHP).
+ foreach ($users as $i => $user) {
+ if (!$user->isActive()) {
+ $users->forget($i);
+ }
+ }
+
+ // Sort by full name
+ $users = User::sortUsers($users);
+
+ $users = \Eventy::filter('mailbox.users_assignable', $users, $this, $cache);
+
+ return $users;
+ }
+
+ /**
+ * Get users IDs who have access to the mailbox.
+ */
+ public function userIdsHavingAccess()
+ {
+ return $this->usersHavingAccess(false, ['users.id', 'users.status'])->pluck('id')->toArray();
+
+ /*$user_ids = $this->users()->pluck('users.id');
+ $admin_ids = User::where('role', User::ROLE_ADMIN)->pluck('id');
+
+ return $user_ids->merge($admin_ids)->unique()->toArray();*/
+ }
+
+ /**
+ * Check if user has access to the mailbox.
+ *
+ * @return bool
+ */
+ public function userHasAccess($user_id, $user = null)
+ {
+ if (!$user) {
+ if ($user_id instanceof \App\User) {
+ $user = $user_id;
+ } else {
+ $user = User::find($user_id);
+ }
+ }
+ $filter = \Eventy::filter('mailbox.user_has_access', -1, $this, $user);
+ if ($filter != -1) {
+ return (bool)$filter;
+ } elseif ($user && $user->isAdmin()) {
+ return true;
+ } else {
+ return (bool) $this->users()->where('users.id', $user_id)->count();
+ }
+ }
+
+ /**
+ * Get From array for the Mail function.
+ *
+ * @param App\User $from_user
+ * @param App\Conversation $conversation
+ *
+ * @return array
+ */
+ public function getMailFrom($from_user = null, $conversation = null)
+ {
+ // Mailbox name by default
+ $name = $this->name;
+
+ if ($this->from_name == self::FROM_NAME_CUSTOM && $this->from_name_custom) {
+ $data = [
+ 'mailbox' => $this,
+ 'mailbox_from_name' => '', // To avoid recursion.
+ 'conversation' => $conversation,
+ 'user' => $from_user ?: auth()->user(),
+ ];
+ $name = \MailHelper::replaceMailVars($this->from_name_custom, $data, false, true);
+ } elseif ($this->from_name == self::FROM_NAME_USER && $from_user) {
+ $name = $from_user->getFullName();
+ }
+
+ return [
+ 'address' => \Eventy::filter('mailbox.get_mail_from_address', $this->email, $from_user, $conversation),
+ 'name' => \Eventy::filter('mailbox.get_mail_from_name', $name, $from_user, $conversation)
+ ];
+ }
+
+ /**
+ * Get corresponding Laravel mail driver name.
+ */
+ public function getMailDriverName()
+ {
+ switch ($this->out_method) {
+ case self::OUT_METHOD_PHP_MAIL:
+ return 'mail';
+
+ case self::OUT_METHOD_SENDMAIL:
+ return 'sendmail';
+
+ case self::OUT_METHOD_SMTP:
+ return 'smtp';
+
+ default:
+ return 'mail';
+ }
+ }
+
+ /**
+ * Get domain part of the mailbox email.
+ *
+ * @return string
+ */
+ public function getEmailDomain()
+ {
+ return explode('@', $this->email)[1];
+ }
+
+ /**
+ * Get outgoing email encryption protocol.
+ *
+ * @return string
+ */
+ public function getOutEncryptionName()
+ {
+ return self::$out_encryptions[$this->out_encryption];
+ }
+
+ /**
+ * Get incoming email encryption protocol.
+ *
+ * @return string
+ */
+ public function getInEncryptionName()
+ {
+ return self::$in_encryptions[$this->in_encryption];
+ }
+
+ /**
+ * Get incoming protocol name.
+ *
+ * @return string
+ */
+ public function getInProtocolName()
+ {
+ return $this->getInProtocols()[$this->in_protocol] ?? '';
+ }
+
+ /**
+ * Get incoming protocol display name for the UI.
+ *
+ * @return array
+ */
+ public static function getInProtocolDisplayNames()
+ {
+ $display_names = self::$in_protocols;
+
+ $display_names[self::IN_PROTOCOL_IMAP] = 'IMAP';
+ $display_names[self::IN_PROTOCOL_POP3] = 'POP3';
+
+ return \Eventy::filter('mailbox.in_protocols.display_names', $display_names);
+ }
+
+ /**
+ * Get available incoming protocols.
+ *
+ * @return array
+ */
+ public static function getInProtocols()
+ {
+ return \Eventy::filter('mailbox.in_protocols', self::$in_protocols);
+ }
+
+ /**
+ * Get pivot table parameters for the user.
+ */
+ public function getUserSettings($user_id)
+ {
+ $mailbox_user = $this->usersWithSettings()->where('users.id', $user_id)->first();
+ if ($mailbox_user) {
+ return $mailbox_user->settings;
+ } else {
+ // Admin may have no record in mailbox_user table
+ return self::getDummySettings();
+ }
+ }
+
+ /**
+ * Create dummy object with default parameters
+ * @return [type] [description]
+ */
+ public static function getDummySettings()
+ {
+ $settings = new \StdClass();
+ $settings->after_send = MailboxUser::AFTER_SEND_NEXT;
+ $settings->hide = false;
+ $settings->mute = false;
+ $settings->access = [];
+
+ return $settings;
+ }
+
+ public function fetchUserSettings($user_id)
+ {
+ $settings = $this->getUserSettings($user_id);
+
+ $this->after_send = $settings->after_send;
+ $this->hide = $settings->hide;
+ $this->mute = $settings->mute;
+ $this->access = $settings->access;
+ }
+
+ /**
+ * Get main email and aliases.
+ *
+ * @return array
+ */
+ public function getEmails()
+ {
+ $emails = [$this->email];
+
+ if ($this->aliases) {
+ $aliases = explode(',', $this->aliases);
+ foreach ($aliases as $alias) {
+ $alias = trim($alias);
+ $alias = preg_replace("#\(.*#", '', $alias);
+
+ $alias = Email::sanitizeEmail($alias);
+ if ($alias) {
+ $emails[] = $alias;
+ }
+ }
+ }
+
+ return $emails;
+ }
+
+ /**
+ * Get mailbox aliases as an associative array.
+ */
+ public function getAliases($include_mailbox_email = true, $check_aliases_reply = false)
+ {
+ if ($check_aliases_reply && !$this->aliases_reply) {
+ return [];
+ }
+
+ if ($include_mailbox_email) {
+ $emails = [$this->email => $this->name];
+ } else {
+ $emails = [];
+ }
+
+ if ($this->aliases) {
+ $aliases = explode(',', $this->aliases);
+ foreach ($aliases as $alias) {
+ $name = '';
+ $alias = trim($alias);
+ preg_match("#[^\(]+\((.*)\)#", $alias, $m);
+ if (!empty($m[1])) {
+ $name = $m[1];
+ $alias = preg_replace("#\(.*#", '', $alias);
+ }
+
+ $alias = Email::sanitizeEmail($alias);
+ if ($alias) {
+ $emails[$alias] = $name;
+ }
+ }
+ }
+
+ return $emails;
+ }
+
+ /**
+ * Remove mailbox email and aliases from the list of emails.
+ *
+ * @param array $list
+ * @param Mailbox $mailbox
+ *
+ * @return array
+ */
+ public function removeMailboxEmailsFromList($list)
+ {
+ if (!is_array($list)) {
+ return [];
+ }
+ $mailbox_emails = $this->getEmails();
+ foreach ($list as $i => $email) {
+ if (in_array($email, $mailbox_emails)) {
+ unset($list[$i]);
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * Get all active mailboxes.
+ *
+ * @return [type] [description]
+ */
+ public static function getActiveMailboxes()
+ {
+ $active = [];
+
+ // It is more effective to retrive all mailboxes and filter them in PHP.
+ $mailboxes = self::all();
+ foreach ($mailboxes as $mailbox) {
+ if ($mailbox->isActive()) {
+ $active[] = $mailbox;
+ }
+ }
+
+ return $active;
+ }
+
+ /**
+ * Get mailbox URL.
+ *
+ * @return [type] [description]
+ */
+ public function url()
+ {
+ return \Eventy::filter('mailbox.url', route('mailboxes.view', ['id' => $this->id]), $this);
+ }
+
+ /**
+ * Fill the model with an array of attributes.
+ *
+ * @param array $attributes [description]
+ *
+ * @return [type] [description]
+ */
+ public function fill(array $attributes)
+ {
+ $this->fillable(array_merge($this->getFillable(), \Eventy::filter('mailbox.fillable_fields', [])));
+
+ return parent::fill($attributes);
+ }
+
+ /**
+ * Set phones as JSON.
+ *
+ * @param array $phones_array
+ */
+ public function setInImapFolders(array $in_imap_folders)
+ {
+ $this->in_imap_folders = json_encode($in_imap_folders);
+ }
+
+ /**
+ * Get list of imap folders.
+ */
+ public function getInImapFolders()
+ {
+ $in_imap_folders = \Helper::jsonToArray($this->in_imap_folders);
+ if (count($in_imap_folders)) {
+ return $in_imap_folders;
+ } else {
+ return ["INBOX"];
+ }
+ }
+
+ public function outPasswordSafe()
+ {
+ return \Helper::safePassword($this->out_password);
+ }
+
+ public function inPasswordSafe()
+ {
+ return \Helper::safePassword($this->in_password);
+ }
+
+ public function getReplySeparator()
+ {
+ return $this->before_reply ?: \MailHelper::REPLY_SEPARATOR_TEXT;
+ }
+
+ public static function findOrFailWithSettings($id, $user_id)
+ {
+ return Mailbox::select(['mailboxes.*', 'mailbox_user.hide', 'mailbox_user.mute', 'mailbox_user.access'])
+ ->where('mailboxes.id', $id)
+ ->leftJoin('mailbox_user', function ($join) use ($user_id) {
+ $join->on('mailbox_user.mailbox_id', '=', 'mailboxes.id');
+ $join->where('mailbox_user.user_id', $user_id);
+ })->firstOrFail();
+ }
+
+ /*public static function getUserSettings($mailbox_id, $user_id)
+ {
+ return MailboxUser::where('mailbox_id', $mailbox_id)
+ ->where('user_id', $user_id)
+ ->first();
+ }*/
+
+ public static function getAccessPermissionName($perm)
+ {
+ $access_permissions = [
+ self::ACCESS_PERM_EDIT => __('Edit Mailbox'),
+ self::ACCESS_PERM_PERMISSIONS => __('Permissions'),
+ self::ACCESS_PERM_AUTO_REPLIES => __('Auto Replies'),
+ self::ACCESS_PERM_SIGNATURE => __('Email Signature'),
+ ];
+ $access_permissions = \Eventy::filter('mailbox.access_permissions_list', $access_permissions);
+
+ return $access_permissions[$perm] ?? '';
+ }
+
+ public static function getAccessPermissionRoute($perm)
+ {
+ $route = self::$access_routes[$perm] ?? '';
+ $route = \Eventy::filter('mailbox.access_permissions_route', $route, $perm);
+
+ return $route;
+ }
+
+ public function getMeta($key, $default = null)
+ {
+ if (isset($this->meta[$key])) {
+ return $this->meta[$key];
+ } else {
+ return $default;
+ }
+ }
+
+ public function setMeta($key, $value)
+ {
+ $meta = $this->meta;
+ $meta[$key] = $value;
+ $this->meta = $meta;
+ }
+
+ public function setMetaParam($param, $value, $save = false)
+ {
+ $meta = $this->meta;
+ $meta[$param] = $value;
+ $this->meta = $meta;
+
+ if ($save) {
+ $this->save();
+ }
+ }
+
+ public function removeMetaParam($param, $save = false)
+ {
+ $meta = $this->meta;
+ if (isset($meta[$param])) {
+ unset($meta[$param]);
+ }
+ $this->meta = $meta;
+
+ if ($save) {
+ $this->save();
+ }
+ }
+
+ /**
+ * Check if there is a user with specified email.
+ */
+ public static function userEmailExists($email)
+ {
+ $email = Email::sanitizeEmail($email);
+ $user = User::where('email', $email)->first();
+
+ if ($user) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public function oauthEnabled()
+ {
+ return !empty($this->meta['oauth']['provider']);
+ }
+
+ public function oauthGetParam($param)
+ {
+ return $this->meta['oauth'][$param] ?? '';
+ }
+
+ public function setEmailAttribute($value)
+ {
+ if ($value) {
+ $this->attributes['email'] = Email::sanitizeEmail($value);
+ }
+ }
+}
diff --git a/freescout-dist/app/MailboxUser.php b/freescout-dist/app/MailboxUser.php
new file mode 100644
index 0000000..31029c3
--- /dev/null
+++ b/freescout-dist/app/MailboxUser.php
@@ -0,0 +1,27 @@
+ 'array',
+ // ];
+}
diff --git a/freescout-dist/app/Misc/Helper.php b/freescout-dist/app/Misc/Helper.php
new file mode 100644
index 0000000..f63d01b
--- /dev/null
+++ b/freescout-dist/app/Misc/Helper.php
@@ -0,0 +1,2122 @@
+ 'dashboard',
+ 'mailbox' => [
+ 'mailboxes.view',
+ 'mailboxes.view.folder',
+ 'conversations.view',
+ 'conversations.create',
+ 'conversations.draft',
+ //'conversations.search',
+ ],
+ 'manage' => [
+ 'settings' => 'settings',
+ 'mailboxes' => [
+ 'mailboxes',
+ 'mailboxes.update',
+ 'mailboxes.create',
+ 'mailboxes.connection',
+ 'mailboxes.connection.incoming',
+ 'mailboxes.permissions',
+ 'mailboxes.auto_reply',
+ ],
+ 'users' => [
+ 'users',
+ 'users.create',
+ 'users.profile',
+ 'users.permissions',
+ 'users.notifications',
+ 'users.password',
+ ],
+ 'logs' => [
+ 'logs',
+ 'logs.app',
+ ],
+ 'system' => [
+ 'system',
+ 'system.tools',
+ ],
+ ],
+ // No menu item selected
+ 'customers' => [],
+ ];
+
+ /**
+ * Locales data.
+ *
+ * @var [type]
+ */
+ public static $locales = [
+ 'af' => ['name' => 'Afrikaans',
+ 'name_en' => 'Afrikaans',
+ ],
+ 'sq' => ['name' => 'Shqip',
+ 'name_en' => 'Albanian',
+ ],
+ 'ar' => ['name' => 'العربية',
+ 'name_en' => 'Arabic',
+ ],
+ 'ar-IQ' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Iraq)',
+ ],
+ 'ar-LY' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Libya)',
+ ],
+ 'ar-MA' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Morocco)',
+ ],
+ 'ar-OM' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Oman)',
+ ],
+ 'ar-SY' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Syria)',
+ ],
+ 'ar-LB' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Lebanon)',
+ ],
+ 'ar-AE' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (U.A.E.)',
+ ],
+ 'ar-QA' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Qatar)',
+ ],
+ 'ar-SA' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Saudi Arabia)',
+ ],
+ 'ar-EG' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Egypt)',
+ ],
+ 'ar-DZ' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Algeria)',
+ ],
+ 'ar-TN' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Tunisia)',
+ ],
+ 'ar-YE' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Yemen)',
+ ],
+ 'ar-JO' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Jordan)',
+ ],
+ 'ar-KW' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Kuwait)',
+ ],
+ 'ar-BH' => ['name' => 'العربية',
+ 'name_en' => 'Arabic (Bahrain)',
+ ],
+ 'az' => ['name' => 'Azerbaijani',
+ 'name_en' => 'Azerbaijani',
+ ],
+ 'eu' => ['name' => 'Euskara',
+ 'name_en' => 'Basque',
+ ],
+ 'be' => ['name' => 'Беларуская',
+ 'name_en' => 'Belarusian',
+ ],
+ 'bn' => ['name' => 'বাংলা',
+ 'name_en' => 'Bengali',
+ ],
+ 'bg' => ['name' => 'Български език',
+ 'name_en' => 'Bulgarian',
+ ],
+ 'ca' => ['name' => 'Català',
+ 'name_en' => 'Catalan',
+ ],
+ 'zh-CN' => ['name' => '简体中文',
+ 'name_en' => 'Chinese (Simplified)',
+ ],
+ 'zh-SG' => ['name' => '简体中文',
+ 'name_en' => 'Chinese (Singapore)',
+ ],
+ 'zh-TW' => ['name' => '简体中文',
+ 'name_en' => 'Chinese (Traditional)',
+ ],
+ 'zh-HK' => ['name' => '简体中文',
+ 'name_en' => 'Chinese (Hong Kong SAR)',
+ ],
+ 'hr' => ['name' => 'Hrvatski',
+ 'name_en' => 'Croatian',
+ ],
+ 'cs' => ['name' => 'Čeština',
+ 'name_en' => 'Czech',
+ ],
+ 'da' => ['name' => 'Dansk',
+ 'name_en' => 'Danish',
+ ],
+ 'nl' => ['name' => 'Nederlands',
+ 'name_en' => 'Dutch',
+ ],
+ // 'nl_BE' => ['name' => 'Nederlands',
+ // 'name_en' => 'Dutch (Belgium)',
+ // ],
+ // 'en_US' => ['name' => 'English',
+ // 'name_en' => 'English (United States)',
+ // ],
+ // 'en_AU' => ['name' => '',
+ // 'name_en' => 'English (Australia)',
+ // ],
+ // 'en_NZ' => ['name' => '',
+ // 'name_en' => 'English (New Zealand)',
+ // ],
+ // 'en_ZA' => ['name' => '',
+ // 'name_en' => 'English (South Africa)',
+ // ],
+ 'en' => ['name' => 'English',
+ 'name_en' => 'English',
+ ],
+ // 'en_TT' => ['name' => '',
+ // 'name_en' => 'English (Trinidad)',
+ // ],
+ // 'en_GB' => ['name' => '',
+ // 'name_en' => 'English (United Kingdom)',
+ // ],
+ // 'en_CA' => ['name' => '',
+ // 'name_en' => 'English (Canada)',
+ // ],
+ // 'en_IE' => ['name' => '',
+ // 'name_en' => 'English (Ireland)',
+ // ],
+ // 'en_JM' => ['name' => '',
+ // 'name_en' => 'English (Jamaica)',
+ // ],
+ // 'en_BZ' => ['name' => '',
+ // 'name_en' => 'English (Belize)',
+ // ],
+ 'et' => ['name' => 'Eesti',
+ 'name_en' => 'Estonian',
+ ],
+ 'fo' => ['name' => 'Føroyskt',
+ 'name_en' => 'Faeroese',
+ ],
+ 'fa' => ['name' => 'فارسی',
+ 'name_en' => 'Farsi',
+ ],
+ 'fi' => ['name' => 'Suomi',
+ 'name_en' => 'Finnish',
+ ],
+ 'fr' => ['name' => 'Français',
+ 'name_en' => 'French',
+ ],
+ // 'fr_CA' => ['name' => '',
+ // 'name_en' => 'French (Canada)',
+ // ],
+ // 'fr_LU' => ['name' => '',
+ // 'name_en' => 'French (Luxembourg)',
+ // ],
+ // 'fr_BE' => ['name' => '',
+ // 'name_en' => 'French (Belgium)',
+ // ],
+ // 'fr_CH' => ['name' => '',
+ // 'name_en' => 'French (Switzerland)',
+ // ],
+ 'gd' => ['name' => 'Gàidhlig',
+ 'name_en' => 'Gaelic (Scotland)',
+ ],
+ 'de' => ['name' => 'Deutsch',
+ 'name_en' => 'German',
+ ],
+ // 'de_CH' => ['name' => '',
+ // 'name_en' => 'German (Switzerland)',
+ // ],
+ // 'de_LU' => ['name' => '',
+ // 'name_en' => 'German (Luxembourg)',
+ // ],
+ // 'de_AT' => ['name' => '',
+ // 'name_en' => 'German (Austria)',
+ // ],
+ // 'de_LI' => ['name' => '',
+ // 'name_en' => 'German (Liechtenstein)',
+ // ],
+ 'el' => ['name' => 'Ελληνικά',
+ 'name_en' => 'Greek',
+ ],
+ 'he' => ['name' => 'עברית',
+ 'name_en' => 'Hebrew',
+ ],
+ 'hi' => ['name' => 'हिन्दी',
+ 'name_en' => 'Hindi',
+ ],
+ 'hu' => ['name' => 'Magyar',
+ 'name_en' => 'Hungarian',
+ ],
+ 'is' => ['name' => 'Íslenska',
+ 'name_en' => 'Icelandic',
+ ],
+ 'id' => ['name' => 'Bahasa Indonesia',
+ 'name_en' => 'Indonesian',
+ ],
+ 'ga' => ['name' => 'Gaeilge',
+ 'name_en' => 'Irish',
+ ],
+ 'it' => ['name' => 'Italiano',
+ 'name_en' => 'Italian',
+ ],
+ // 'it_CH' => ['name' => 'Italiano',
+ // 'name_en' => 'Italian (Switzerland)',
+ // ],
+ 'ja' => ['name' => '日本語',
+ 'name_en' => 'Japanese',
+ ],
+ 'ko' => ['name' => '한국어 (韓國語)',
+ 'name_en' => 'Korean (Johab)',
+ ],
+ 'lv' => ['name' => 'Latviešu valoda',
+ 'name_en' => 'Latvian',
+ ],
+ 'lt' => ['name' => 'Lietuvių kalba',
+ 'name_en' => 'Lithuanian',
+ ],
+ 'mk' => ['name' => 'Македонски јазик',
+ 'name_en' => 'Macedonian (FYROM)',
+ ],
+ 'ms' => ['name' => 'Bahasa Melayu, بهاس ملايو',
+ 'name_en' => 'Malay',
+ ],
+ 'mt' => ['name' => 'Malti',
+ 'name_en' => 'Maltese',
+ ],
+ 'ne' => ['name' => 'नेपाली',
+ 'name_en' => 'Nepali',
+ ],
+ 'no' => ['name' => 'Norsk bokmål',
+ 'name_en' => 'Norwegian (Bokmal)',
+ ],
+ 'pl' => ['name' => 'Polski',
+ 'name_en' => 'Polish',
+ ],
+ 'pt-PT' => ['name' => 'Português',
+ 'name_en' => 'Portuguese (Portugal)',
+ ],
+ 'pt-BR' => ['name' => 'Português do Brasil',
+ 'name_en' => 'Portuguese (Brazil)',
+ ],
+ 'ro' => ['name' => 'Română',
+ 'name_en' => 'Romanian',
+ ],
+ // 'ro_MO' => ['name' => 'Română',
+ // 'name_en' => 'Romanian (Republic of Moldova)',
+ // ],
+ 'rm' => ['name' => 'Rumantsch grischun',
+ 'name_en' => 'Romansh',
+ ],
+ 'ru' => ['name' => 'Русский',
+ 'name_en' => 'Russian',
+ ],
+ // 'ru_MO' => ['name' => '',
+ // 'name_en' => 'Russian (Republic of Moldova)',
+ // ],
+ 'sz' => ['name' => 'Davvisámegiella',
+ 'name_en' => 'Sami (Lappish)',
+ ],
+ 'sr' => ['name' => 'Српски језик',
+ 'name_en' => 'Serbian (Latin)',
+ ],
+ 'sk' => ['name' => 'Slovenčina',
+ 'name_en' => 'Slovak',
+ ],
+ 'sl' => ['name' => 'Slovenščina',
+ 'name_en' => 'Slovenian',
+ ],
+ /*'sb' => ['name' => 'Serbsce',
+ 'name_en' => 'Sorbian',
+ ],*/
+ 'es' => ['name' => 'Español',
+ 'name_en' => 'Spanish',
+ ],
+ // 'es_GT' => ['name' => '',
+ // 'name_en' => 'Spanish (Guatemala)',
+ // ],
+ // 'es_PA' => ['name' => '',
+ // 'name_en' => 'Spanish (Panama)',
+ // ],
+ // 'es_VE' => ['name' => '',
+ // 'name_en' => 'Spanish (Venezuela)',
+ // ],
+ // 'es_PE' => ['name' => '',
+ // 'name_en' => 'Spanish (Peru)',
+ // ],
+ // 'es_EC' => ['name' => '',
+ // 'name_en' => 'Spanish (Ecuador)',
+ // ],
+ // 'es_UY' => ['name' => '',
+ // 'name_en' => 'Spanish (Uruguay)',
+ // ],
+ // 'es_BO' => ['name' => '',
+ // 'name_en' => 'Spanish (Bolivia)',
+ // ],
+ // 'es_HN' => ['name' => '',
+ // 'name_en' => 'Spanish (Honduras)',
+ // ],
+ // 'es_PR' => ['name' => '',
+ // 'name_en' => 'Spanish (Puerto Rico)',
+ // ],
+ // 'es_MX' => ['name' => '',
+ // 'name_en' => 'Spanish (Mexico)',
+ // ],
+ // 'es_CR' => ['name' => '',
+ // 'name_en' => 'Spanish (Costa Rica)',
+ // ],
+ // 'es_DO' => ['name' => '',
+ // 'name_en' => 'Spanish (Dominican Republic)',
+ // ],
+ // 'es_CO' => ['name' => '',
+ // 'name_en' => 'Spanish (Colombia)',
+ // ],
+ // 'es_AR' => ['name' => '',
+ // 'name_en' => 'Spanish (Argentina)',
+ // ],
+ // 'es_CL' => ['name' => '',
+ // 'name_en' => 'Spanish (Chile)',
+ // ],
+ // 'es_PY' => ['name' => '',
+ // 'name_en' => 'Spanish (Paraguay)',
+ // ],
+ // 'es_SV' => ['name' => '',
+ // 'name_en' => 'Spanish (El Salvador)',
+ // ],
+ // 'es_NI' => ['name' => '',
+ // 'name_en' => 'Spanish (Nicaragua)',
+ // ],
+ 'sv' => ['name' => 'Svenska',
+ 'name_en' => 'Swedish',
+ ],
+ // unknown
+ // 'sx' => ['name' => '',
+ // 'name_en' => 'Sutu',
+ // ],
+ // 'sv_FI' => ['name' => '',
+ // 'name_en' => 'Swedish (Finland)',
+ // ],
+ 'th' => ['name' => 'ไทย',
+ 'name_en' => 'Thai',
+ ],
+ 'ts' => ['name' => 'Xitsonga',
+ 'name_en' => 'Tsonga',
+ ],
+ 'tn' => ['name' => 'Setswana',
+ 'name_en' => 'Tswana',
+ ],
+ 'tr' => ['name' => 'Türkçe',
+ 'name_en' => 'Turkish',
+ ],
+ 'uk' => ['name' => 'українська',
+ 'name_en' => 'Ukrainian',
+ ],
+ 'ur' => ['name' => 'اردو',
+ 'name_en' => 'Urdu',
+ ],
+ 've' => ['name' => 'Tshivenḓa',
+ 'name_en' => 'Venda',
+ ],
+ 'vi' => ['name' => 'Tiếng Việt',
+ 'name_en' => 'Vietnamese',
+ ],
+ 'xh' => ['name' => 'isiXhosa',
+ 'name_en' => 'Xhosa',
+ ],
+ 'ji' => ['name' => 'ייִדיש',
+ 'name_en' => 'Yiddish',
+ ],
+ 'zu' => ['name' => 'isiZulu',
+ 'name_en' => 'Zulu',
+ ],
+ ];
+
+ /**
+ * Cache time of the DB query.
+ */
+ public static function cacheTime($enabled = true)
+ {
+ if ($enabled) {
+ return self::QUERY_CACHE_TIME;
+ } else {
+ return 0;
+ }
+ }
+
+ public static function setGlobalEntity($name, $entity)
+ {
+ self::$global_entities[$name] = $entity;
+ }
+
+ public static function getGlobalEntity($name)
+ {
+ return self::$global_entities[$name] ?? null;
+ }
+
+ /**
+ * Remove from text all tags, double spaces, etc.
+ */
+ public static function stripTags($text)
+ {
+ // Remove all kinds of spaces after tags.
+ // https://stackoverflow.com/questions/3230623/filter-all-types-of-whitespace-in-php
+ $text = preg_replace("/^(.*)>[\r\n]*\s+/mu", '$1>', $text ?? '');
+
+ // Remove #is', '', $text);
+ $text = preg_replace('##is', '', $text);
+
+ // Remove tags.
+ $text = strip_tags($text);
+ $text = preg_replace('/\s+/mu', ' ', $text);
+
+ // Trim
+ $text = trim($text);
+ $text = preg_replace('/^\s+/mu', '', $text);
+
+ // Causes "General error: 1366 Incorrect string value"
+ // 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));
+
+ return $text;
+ }
+
+ /**
+ * Get preview of the text in a plain form.
+ */
+ public static function textPreview($text, $length = self::PREVIEW_MAXLENGTH)
+ {
+ $text = strtr($text ?? '', [
+ '' => ' ',
+ '
', '', $text); + $text = str_ireplace('', '', $text); + + if ($embed_images) { + // Replace embedded images with their urls. + $text = preg_replace( '/]*src=\"([^>"]+)\"[^>]*>/i', "$1", $text); + } + return (new \Html2Text\Html2Text($text, $options))->getText(); + } + + /** + * Trim text removing non-breaking spaces also. + * + * @param [type] $text [description] + * @return [type] [description] + */ + public static function trim($text) + { + $text = preg_replace("/^\s+/u", '', $text); + $text = preg_replace("/\s+$/u", '', $text); + + return $text; + } + + /** + * Unicode escape sequences like “\u00ed” to proper UTF-8 encoded characters. + * + * @param [type] $text [description] + * @return [type] [description] + */ + public static function entities2utf8($text) + { + try { + return json_decode('"'.str_replace('"', '\\"', $text).'"'); + } catch(\Exception $e) { + return $text; + } + } + + /** + * Get app subdirectory in /subdirectory/1/2/ format. + */ + public static function getSubdirectory($keep_trailing_slash = false, $keep_front_slash = false) + { + $subdirectory = ''; + + $app_url = config('app.url'); + + // Check host to ignore default values. + $app_host = parse_url($app_url, PHP_URL_HOST); + + if ($app_url && !in_array($app_host, ['localhost', 'example.com'])) { + $subdirectory = parse_url($app_url, PHP_URL_PATH); + } else { + // Before app is installed + $subdirectory = $_SERVER['PHP_SELF']; + + $filename = basename($_SERVER['SCRIPT_FILENAME']); + + if (basename($_SERVER['SCRIPT_NAME']) === $filename) { + $subdirectory = $_SERVER['SCRIPT_NAME']; + } elseif (basename($_SERVER['PHP_SELF']) === $filename) { + $subdirectory = $_SERVER['PHP_SELF']; + } elseif (array_key_exists('ORIG_SCRIPT_NAME', $_SERVER) && basename($_SERVER['ORIG_SCRIPT_NAME']) === $filename) { + $subdirectory = $_SERVER['ORIG_SCRIPT_NAME']; // 1and1 shared hosting compatibility + } else { + // Backtrack up the script_filename to find the portion matching + // php_self + $path = $_SERVER['PHP_SELF']; + $file = $_SERVER['SCRIPT_FILENAME']; + $segs = explode('/', trim($file, '/')); + $segs = array_reverse($segs); + $index = 0; + $last = \count($segs); + $subdirectory = ''; + do { + $seg = $segs[$index]; + $subdirectory = '/'.$seg.$subdirectory; + ++$index; + } while ($last > $index && (false !== $pos = strpos($path, $subdirectory)) && 0 != $pos); + } + } + + if ($subdirectory === null) { + $subdirectory = ''; + } + + $subdirectory = str_replace('public/index.php', '', $subdirectory); + $subdirectory = str_replace('index.php', '', $subdirectory); + + $subdirectory = trim($subdirectory, '/'); + if ($keep_trailing_slash) { + $subdirectory .= '/'; + } + + if ($keep_front_slash && $subdirectory != '/') { + $subdirectory = '/'.$subdirectory; + } + + return $subdirectory; + } + + /** + * Check current route. + */ + public static function isRoute($route_name) + { + $route = \Route::current(); + if (!$route) { + return false; + } + $current = $route->getName(); + + if (is_array($route_name)) { + return in_array($current, $route_name); + } else { + return ($current == $route_name); + } + } + + /** + * Check if passed app URL has default Laravel value. + */ + public static function isDefaultAppUrl($app_url) + { + $app_host = parse_url($app_url, PHP_URL_HOST); + + if ($app_url && !in_array($app_host, ['localhost', 'example.com'])) { + return false; + } else { + return true; + } + } + + /** + * Stop all queue:work processes. + */ + public static function queueWorkerRestart() + { + \Cache::forever('illuminate:queue:restart', Carbon::now()->getTimestamp()); + // In some systems queue:work runs on a separate file system, + // so those queue:work processes may not get illuminate:queue:restart. + $job_exists = \App\Job::where('queue', 'default') + ->where('payload', 'like', '{"displayName":"App\\\\\\\\Jobs\\\\\\\\RestartQueueWorker"%') + ->exists(); + if (!$job_exists) { + \App\Jobs\RestartQueueWorker::dispatch()->onQueue('default'); + } + } + + /** + * UTF-8 split text into parts with max. length. + */ + public static function strSplitKeepWords($str, $max_length = 75) + { + $array_words = explode(' ', $str); + + $currentLength = 0; + + $index = 0; + + $array_output = ['']; + + foreach ($array_words as $word) { + // +1 because the word will receive back the space in the end that it loses in explode() + $wordLength = strlen($word) + 1; + + if (($currentLength + $wordLength) <= $max_length) { + $array_output[$index] .= $word . ' '; + + $currentLength += $wordLength; + } else { + $index += 1; + + $currentLength = $wordLength; + + $array_output[$index] = $word; + } + } + + return $array_output; + } + + /** + * Replace new line with doble
. + */ + public static function nl2brDouble($text) + { + return str_replace('
', '
', nl2br($text)); + } + + /** + * Decode \u00ed. + */ + public static function decodeUnicode($str) + { + $str = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function ($match) { + return mb_convert_encoding(pack('H*', $match[1]), 'UTF-8', 'UCS-2BE'); + }, $str); + + return $str; + } + + /** + * Convert text into json without converting chars into \u0411. + */ + public static function jsonEncodeUtf8($text) + { + return json_encode($text, JSON_UNESCAPED_UNICODE); + } + + /** + * It looks like this is not used anywhere. + * Json encode to avoid "Unable to JSON encode payload. Error code: 5" + */ + // public static function jsonEncodeSafe($value, $options = 0, $depth = 512, $utfErrorFlag = false) + // { + // $encoded = json_encode($value, $options, $depth); + // switch (json_last_error()) { + // case JSON_ERROR_NONE: + // return $encoded; + // // case JSON_ERROR_DEPTH: + // // return 'Maximum stack depth exceeded'; // or trigger_error() or throw new Exception() + // // case JSON_ERROR_STATE_MISMATCH: + // // return 'Underflow or the modes mismatch'; // or trigger_error() or throw new Exception() + // // case JSON_ERROR_CTRL_CHAR: + // // return 'Unexpected control character found'; + // // case JSON_ERROR_SYNTAX: + // // return 'Syntax error, malformed JSON'; // or trigger_error() or throw new Exception() + // case JSON_ERROR_UTF8: + // $clean = self::utf8ize($value); + // if ($utfErrorFlag) { + // //return 'UTF8 encoding error'; // or trigger_error() or throw new Exception() + // } + // return self::jsonEncodeSafe($clean, $options, $depth, true); + // // default: + // // return 'Unknown error'; // or trigger_error() or throw new Exception() + + // } + // } + + // public static function utf8ize($mixed) + // { + // if (is_array($mixed)) { + // foreach ($mixed as $key => $value) { + // $mixed[$key] = self::utf8ize($value); + // } + // } else if (is_string ($mixed)) { + // return utf8_encode($mixed); + // } + // return $mixed; + // } + + /** + * Check if host is available on the port specified. + */ + public static function checkPort($host, $port, $timeout = 10) + { + $connection = @fsockopen($host, $port); + if (is_resource($connection)) { + fclose($connection); + return true; + } else { + return false; + } + } + + public static function purifyHtml($html) + { + if (!$html) { + return $html; + } + + $html = \Purifier::clean($html); + + // It's not clear why it was needed to remove spaces after tags. + // + // Remove all kinds of spaces after tags + // https://stackoverflow.com/questions/3230623/filter-all-types-of-whitespace-in-php + //$html = preg_replace("/^(.*)>[\r\n]*\s+/mu", '$1>', $html); + + return $html; + } + + /** + * Replace password with asterisks. + */ + public static function safePassword($password) + { + return str_repeat("*", mb_strlen($password ?? '')); + } + + /** + * Turn all URLs in clickable links. + * Released under public domain + * https://gist.github.com/jasny/2000705 + * + * @param string $value + * @param array $protocols http/https, ftp, mail + * @param array $attributes + * @return string + */ + public static function linkify($value, $protocols = ['http', 'mail'], array $attributes = []) + { + // Link attributes + $attr = ''; + foreach ($attributes as $key => $val) { + $attr .= ' ' . $key . '="' . htmlentities($val) . '"'; + } + + $links = array(); + + // Extract existing links and tags + $value = preg_replace_callback('~(.*?|<.*?>)~i', function ($match) use (&$links) { return '<' . array_push($links, $match[1]) . '>'; }, $value ?? '') ?: $value; + + $value = $value ?? ''; + + // Extract text links for each protocol + foreach ((array)$protocols as $protocol) { + switch ($protocol) { + case 'http': + case 'https': + //$value = preg_replace_callback('~(?:(https?)://([^\s<]+)|(www\.[^\s<]+?\.[^\s<]+))(?]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))%s', function ($match) use ($protocol, &$links, $attr) { + // https://github.com/freescout-helpdesk/freescout/issues/3402 + $value = preg_replace_callback('%([>\r\n\s:;\( ]|^)((([\w-]+)://?|www[.])[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))%s', function ($match) use ($protocol, &$links, $attr) { + if ($match[4]) { + $protocol = $match[4]; + } + $link = $match[2]; + $link = substr($link, strlen($match[3])); + //return '<' . array_push($links, "$protocol://$link") . '>'; + return $match[1].'<' . array_push($links, "".$match[2]."") . '>'; + }, $value) ?: $value; + break; + case 'mail': $value = preg_replace_callback('~([^\s<>]+?@[^\s<]+?\.[^\s<]+)(?{$match[1]}") . '>'; }, $value) ?: $value; + break; + default: $value = preg_replace_callback('~' . preg_quote($protocol, '~') . '://([^\s<]+?)(?$protocol://{$match[1]}") . '>'; }, $value) ?: $value; + break; + } + } + + // Insert all links + return preg_replace_callback('/<(\d+)>/', function ($match) use (&$links) { return $links[$match[1] - 1]; }, $value ?? '') ?: $value; + } + + /** + * Generates unique ID of the application. + */ + public static function getAppIdentifier() + { + $identifier = md5(config('app.key').parse_url(config('app.url'), PHP_URL_HOST)); + + return $identifier; + } + + /** + * Are we in the mobile app. + */ + public static function isInApp() + { + return (int)app('request')->cookie('in_app'); + } + + /** + * Get identifier for queue:work + */ + public static function getWorkerIdentifier($salt = '') + { + return md5((config('app.key') ?? '').$salt); + } + + /** + * Get pids of the specified processes. + */ + public static function getRunningProcesses($search = '') + { + if (empty($search)) { + $search = \Helper::getWorkerIdentifier(); + } + + $pids = []; + + try { + $processes = preg_split("/[\r\n]/", shell_exec("ps aux | grep '".$search."'")); + foreach ($processes as $process) { + $process = trim($process); + preg_match("/^[\S]+\s+([\d]+)\s+/", $process, $m); + if (empty($m)) { + // Another format (used in Docker image). + // 1713 nginx 0:00 /usr/bin/php82... + preg_match("/^([\d]+)\s+[\S]+\s+/", $process, $m); + } + if (!preg_match("/(sh \-c|grep )/", $process) && !empty($m[1])) { + $pids[] = $m[1]; + } + } + } catch (\Exception $e) { + // Do nothing + } + return $pids; + } + + public static function uploadFile($file, $allowed_exts = [], $allowed_mimes = []) + { + $ext = strtolower($file->getClientOriginalExtension()); + + if ($allowed_exts) { + if (!in_array($ext, $allowed_exts)) { + throw new \Exception(__('Unsupported file type'), 1); + } + } + + if ($allowed_mimes) { + $mime_type = $file->getMimeType(); + if (!in_array($mime_type, $allowed_mimes)) { + throw new \Exception(__('Unsupported file type'), 1); + } + } + $file_name = \Str::random(25).'.'.$ext; + + $file_name = \Helper::sanitizeUploadedFileName($file_name, $file); + + $file->storeAs('uploads', $file_name); + + self::sanitizeUploadedFileData('uploads'.DIRECTORY_SEPARATOR.$file_name, self::getPublicStorage()); + + return self::uploadedFilePath($file_name); + } + + public static function sanitizeUploadedFileData($file_path, $storage, $content = null) + { + // Remove #is', '', $content); + } + $storage->put($file_path, $clean_content); + } + } + } + + public static function uploadedFileRemove($name) + { + \Storage::delete('uploads/'.$name); + } + + public static function uploadedFilePath($name) + { + return storage_path('uploads/'.$name); + } + + public static function uploadedFileUrl($name) + { + return \Storage::url('uploads/'.$name); + } + + public static function addSessionError($text, $key = 'default') + { + $errors = \Session::get('errors', new \Illuminate\Support\ViewErrorBag); + + if (! $errors instanceof \Illuminate\Support\ViewErrorBag) { + $errors = new \Illuminate\Support\ViewErrorBag; + } + + $message_bag = new \Illuminate\Support\MessageBag; + $message_bag->add($key, $text); + + \Session::flash( + 'errors', $errors->put('default', $message_bag) + ); + } + + public static function addFloatingFlash($text, $type = 'danger', $role = '') + { + $flashes = \Session::get('flashes_floating', []); + + $flashes[] = [ + 'text' => $text, + 'type' => $type, + 'role' => $role, + ]; + + \Session::flash('flashes_floating', $flashes); + } + + public static function isMySql() + { + return \DB::connection()->getPDO()->getAttribute(\PDO::ATTR_DRIVER_NAME) == 'mysql'; + } + + public static function isPgSql() + { + return \DB::connection()->getPDO()->getAttribute(\PDO::ATTR_DRIVER_NAME) == 'pgsql'; + } + + public static function sqlLikeOperator() + { + return self::isPgSql() ? 'ilike' : 'like'; + } + + // PostgreSQL truncates string if it contains \u0000 symbol starting from this symbol. + // https://stackoverflow.com/questions/31671634/handling-unicode-sequences-in-postgresql + // https://github.com/freescout-helpdesk/freescout/issues/3485 + public static function sqlSanitizeString($string) + { + return str_replace(json_decode('"\u0000"'), "", $string); + } + + public static function humanFileSize($size, $unit="") + { + if ((!$unit && $size >= 1<<30) || $unit == "GB") { + return number_format($size/(1<<30),2)."GB"; + } + if ((!$unit && $size >= 1<<20) || $unit == "MB") { + return number_format($size/(1<<20),2)."MB"; + } + //if ((!$unit && $size >= 1<<10) || $unit == "KB") { + return number_format($size/(1<<10),2)."KB"; + // } + // return number_format($size)." bytes"; + } + + public static function isPrint() + { + return (bool)app('request')->input('print'); + } + + public static function isDev() + { + return config('app.env') != 'production'; + } + + public static function substrUnicode($str, $s, $l = null) + { + return join("", array_slice(preg_split("//u", $str, -1, PREG_SPLIT_NO_EMPTY), $s, $l)); + } + + /** + * Disable sql_require_primary_key option to avoid errors when migrating. + * Only for MySQL. + */ + public static function disableSqlRequirePrimaryKey() + { + if (!self::isMySql()) { + return; + } + try { + \DB::statement('SET SESSION sql_require_primary_key=0'); + } catch (\Exception $e) { + // General error: 1193 Unknown system variable 'sql_require_primary_key'. + // Do nothing. + } + } + + public static function downloadRemoteFileAsTmp($uri) + { + try { + $contents = self::getRemoteFileContents($uri); + + if (!$contents) { + return false; + } + + $temp_file = self::getTempFileName(); + + \File::put($temp_file, $contents); + + return $temp_file; + + } catch (\Exception $e) { + + \Helper::logException($e, 'Error downloading a remote file ('.$uri.'): '); + + return false; + } + } + + // Replacement for file_get_contents() as some hostings + // do not allow reading remote files via allow_url_fopen option. + public static function getRemoteFileContents($url) + { + try { + $headers = get_headers($url); + + // 307 - Temporary Redirect. + if (!preg_match("/(200|301|302|307)/", $headers[0])) { + return false; + } + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_URL, $url); + \Helper::setCurlDefaultOptions($ch); + curl_setopt($ch, CURLOPT_TIMEOUT, 180); + $contents = curl_exec($ch); + + if (curl_errno($ch)) { + throw new \Exception(curl_errno($ch).' '.curl_error($ch), 1); + } + + curl_close($ch); + + if (!$contents) { + return false; + } + + return $contents; + + } catch (\Exception $e) { + + \Helper::logException($e, 'Error downloading a remote file ('.$url.'): '); + + return false; + } + } + + public static function getTempDir() + { + return sys_get_temp_dir() ?: '/tmp'; + } + + public static function getTempFileName() + { + return tempnam(self::getTempDir(), self::getTempFilePrefix()); + } + + public static function getTempFilePrefix() + { + return 'fs-'.substr(md5(config('app.key').'temp_prefix'), 0, 8).'_'; + } + + // Keep in mind that $uploaded_file->getClientMimeType() returns + // incorrect mime type for images: application/octet-stream + public static function downloadRemoteFileAsTmpFile($uri) + { + $file_path = self::downloadRemoteFileAsTmp($uri); + if ($file_path) { + return new \Illuminate\Http\UploadedFile( + $file_path, basename($file_path), + null, null, true + ); + } else { + return null; + } + } + + public static function sanitizeUploadedFileName($file_name, $uploaded_file = null, $contents = null) + { + // Check extension. + $ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION)); + + if (preg_match('/('.implode('|', self::$restricted_extensions).')/', $ext)) { + // Add underscore to the extension if file has restricted extension. + $file_name = $file_name.'_'; + } elseif ($ext == 'pdf') { + // Rename PDF to avoid running embedded JavaScript. + if ($uploaded_file && !$contents) { + $contents = file_get_contents($uploaded_file->getRealPath()); + } + if ($contents && strstr($contents, '/JavaScript')) { + $file_name = $file_name.'_'; + } + } + + // Remove illegal chars. + $illegal_chars = [ + // Unix. + '/', + chr(0), + // Windows. + '<', + '>', + ':', + '"', + '/', + '\\', + '|', + '?', + '*', + // Macos. + ':', + ]; + // 0-31 (ASCII control characters) for Windows. + for ($i = 0; $i < 32; $i++) { + $illegal_chars[] = chr($i); + } + + $escaped_regex = preg_quote(implode('', $illegal_chars), '/'); + + // https://github.com/freescout-helpdesk/freescout/issues/3377 + $file_name = mb_convert_encoding($file_name, 'UTF-8', 'UTF-8'); + $file_name = preg_replace('/[' . $escaped_regex . ']/', '_', $file_name); + $file_name = preg_replace("/[\t\r\n]/", '', $file_name); + + return $file_name; + } + + public static function remoteFileName($file_url) + { + return preg_replace("/\?.*/", '', basename($file_url)); + } + + public static function binaryDataMimeType($data) + { + $finfo = new \finfo(FILEINFO_MIME_TYPE); + return $finfo->buffer($data); + } + + /** + * https://php.watch/versions/8.0/deprecated-reflectionparameter-methods + */ + public static function getClass($param) + { + return $param->getType() && !$param->getType()->isBuiltin() ? new \ReflectionClass(method_exists($param->getType(), 'getName') ? $param->getType()->getName() : $param->getClass()->name) : null; + } + + /** + * https://php.watch/versions/8.0/deprecated-reflectionparameter-methods + */ + public static function getClassName($param) + { + return $param->getType() && !$param->getType()->isBuiltin() ? method_exists($param->getType(), 'getName') ? $param->getType()->getName() : $param->getClass()->name : null; + } + + public static function getWebCronHash() + { + return md5(config('app.key').'web_cron_hash'); + } + + public static function getProtocol($url = '') + { + return mb_strtolower(parse_url($url ?: config('app.url'), PHP_URL_SCHEME) ?: 'http'); + } + + public static function isHttps($url = '') + { + if (\Helper::isInstaller()) { + // In the Installer we determine HTTPS from URL. + return self::isCurrentUrlHttps(); + } else { + return self::getProtocol($url) == 'https'; + } + } + + public static function isInstaller() + { + $request_uri = $_SERVER['REQUEST_URI'] ?? ''; + $request_uri = preg_replace("#\?.*#", '', $request_uri); + + return strstr($request_uri, '/install/') || preg_match("#/install$#", $request_uri); + } + + public static function isCurrentUrlHttps() + { + if (in_array(strtolower($_SERVER['X_FORWARDED_PROTO'] ?? ''), array('https', 'on', 'ssl', '1'), true) + || strtolower($_SERVER['HTTPS'] ?? '') == 'on' + || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') == 'https' + || ($_SERVER['HTTP_CF_VISITOR'] ?? '') == '{"scheme":"https"}' + ) { + return true; + } else { + return false; + } + } + + public static function fixProtocol($url) + { + if (self::getProtocol() == 'http' && parse_url($url, PHP_URL_SCHEME) != 'http') { + return str_replace('https://', 'http://', $url); + } + + if (self::getProtocol() == 'https' && parse_url($url, PHP_URL_SCHEME) != 'https') { + return str_replace('http://', 'https://', $url); + } + + return $url; + } + + /** + * Fix and parse date to Carbon. + */ + public static function parseDateToCarbon($date, $current_if_invalid = true) + { + if (preg_match('/\+0580/', $date)) { + $date = str_replace('+0580', '+0530', $date); + } + $date = trim(rtrim($date)); + $date = preg_replace('/[<>]/', '', $date); + $date = str_replace('_', ' ', $date); + try { + return Carbon::parse($date); + } catch (\Exception $e) { + switch (true) { + case preg_match('/([0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ UT)+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ UT)+$/i', $date) > 0: + $date .= 'C'; + break; + case preg_match('/([A-Z]{2,3}[\,|\ \,]\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}.*)+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \(.*)\)+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}\, \ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \(.*)\)+$/i', $date) > 0: + case preg_match('/([0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{2,4}\ [0-9]{2}\:[0-9]{2}\:[0-9]{2}\ [A-Z]{2}\ \-[0-9]{2}\:[0-9]{2}\ \([A-Z]{2,3}\ \-[0-9]{2}:[0-9]{2}\))+$/i', $date) > 0: + $array = explode('(', $date); + $array = array_reverse($array); + $date = trim(array_pop($array)); + break; + } + try { + return Carbon::parse($date); + } catch (\Exception $_e) { + if ($current_if_invalid) { + return Carbon::now(); + } else { + return null; + } + } + } + + return null; + } + + public static function urlHome() + { + return \Config::get('app.url'); + // $url = rtrim($url, "/"); + // return $url.'/home'; + } + + /** + * Request::url() may return URL with incorrect protocol. + * Use \Request::getRequestUri() instead. + */ + /*public static function currentUrl() + { + $url = \Request::urlFull(); + if (\Str::startsWith(config('app.url'), 'http://') && !\Str::startsWith($url, 'http://')) { + $url = str_replace('https://', 'http://', $url); + } + if (\Str::startsWith(config('app.url'), 'https://') && !\Str::startsWith($url, 'https://')) { + $url = str_replace('http://', 'https://', $url); + } + return $url; + }*/ + + public static function isLocaleRtl(): bool + { + return in_array(app()->getLocale(), config("app.locales_rtl") ?? []); + } + + public static function phoneToNumeric($phone) + { + $phone = preg_replace("/[^0-9]/", '', $phone); + return (string)$phone; + } + + public static function checkRequiredExtensions() + { + $php_extensions = []; + $required_extensions = \Config::get('installer.requirements.php'); + + // Optional. + $required_extensions[] = 'intl'; + + foreach ($required_extensions as $extension_name) { + $alternatives = explode('/', $extension_name); + if ($alternatives) { + foreach ($alternatives as $alternative) { + $php_extensions[$extension_name] = extension_loaded(trim($alternative)); + if ($php_extensions[$extension_name]) { + break; + } + } + } else { + $php_extensions[$extension_name] = extension_loaded($extension_name); + } + } + + // Required in console. + if (self::isConsole() || !function_exists('shell_exec')) { + $pcntl_enabled = extension_loaded('pcntl'); + } else { + $pcntl_enabled = preg_match("/enable/m", shell_exec("php -i | grep pcntl") ?? ''); + } + $php_extensions['pcntl (console PHP)'] = $pcntl_enabled; + + return $php_extensions; + } + + public static function checkRequiredFunctions() + { + return [ + 'shell_exec (PHP)' => function_exists('shell_exec'), + 'proc_open (PHP)' => function_exists('proc_open'), + 'fpassthru (PHP)' => function_exists('fpassthru'), + 'symlink (PHP)' => function_exists('symlink'), + 'pcntl_signal (console PHP)' => function_exists('shell_exec') ? (int)shell_exec('php -r "echo (int)function_exists(\'pcntl_signal\');"') : false, + 'ps (shell)' => function_exists('shell_exec') ? shell_exec('ps') : false, + ]; + } + + public static function isInstalled() + { + return file_exists(storage_path().DIRECTORY_SEPARATOR.'.installed'); + } + + public static function isConsole() + { + return app()->runningInConsole(); + } + + /** + * Show a warning when background jobs sending emails + * are not processed for some time. + * https://github.com/freescout-helpdesk/freescout/issues/2808 + */ + public static function maybeShowSendingProblemsAlert() + { + $flashes = []; + + if (\Option::get('send_emails_problem')) { + $flashes[] = [ + 'type' => 'warning', + 'text' => __('There is a problem processing outgoing mail queue — an admin should check :%a_begin%System Status:%a_end% and :%a_begin_recommendations%Recommendations:%a_end%', ['%a_begin%' => '', '%a_end%' => '', /*'%a_begin_logs%' => '',*/ '%a_begin_recommendations%' => '']), + 'unescaped' => true, + ]; + } + + return $flashes; + } + + public static function mbUcfirst($string, $encoding = 'UTF-8') + { + $first_char = mb_substr($string, 0, 1, $encoding); + $then = mb_substr($string, 1, null, $encoding); + return mb_strtoupper($first_char, $encoding) . $then; + } + + /** + * This is needed to allow using regexes for large texts. + */ + public static function setPcreBacktrackLimit() + { + if ((int)ini_get('pcre.backtrack_limit') <= 1000000) { + ini_set('pcre.backtrack_limit', 1000000000); + } + } + + /** + * Get client IP address. + */ + public static function getClientIp() + { + // Fix for CloudFlare: https://laracasts.com/discuss/channels/laravel/cloudflare-and-user-ip + // But if CloudFlare is not used any value can be set to "Cf-Connecting-Ip" header. + // if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) { + // $_SERVER['REMOTE_ADDR'] = $_SERVER["HTTP_CF_CONNECTING_IP"]; + // } + return request()->ip(); + } + + public static function getTimeFormat() + { + $user = auth()->user(); + + if ($user) { + return $user->time_format; + } else { + return Option::get('time_format', User::TIME_FORMAT_24); + } + } + + public static function isTimeFormat24() + { + return self::getTimeFormat() == User::TIME_FORMAT_24; + } + + /** + * Runs artisan command and returns it's output. + */ + public static function runCommand($command, $options = []) + { + $output_buffer = new BufferedOutput(); + \Artisan::call($command, $options, $output_buffer); + + return $output_buffer->fetch(); + } + + public static function setCurlDefaultOptions($ch) + { + // Curl has default CURLOPT_CONNECTTIMEOUT=30 seconds. + curl_setopt($ch, CURLOPT_TIMEOUT, config('app.curl_timeout')); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, config('app.curl_connect_timeout')); + curl_setopt($ch, CURLOPT_PROXY, config('app.proxy')); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, config('app.curl_ssl_verifypeer')); + } + + public static function setGuzzleDefaultOptions($params) + { + $default_params = [ + 'timeout' => config('app.curl_timeout'), + 'connect_timeout' => config('app.curl_connect_timeout'), + 'proxy' => config('app.proxy'), + // https://docs.guzzlephp.org/en/6.5/request-options.html#verify + 'verify' => config('app.curl_ssl_verifypeer'), + ]; + + return array_merge($default_params, $params); + } + + public static function cspNonce() + { + if (self::$csp_nonce === null) { + self::$csp_nonce = \Str::random(25); + } + + return self::$csp_nonce; + } + + public static function cspMetaTag() + { + if (!config('app.csp_enabled')) { + return ''; + } + + $nonce = \Helper::cspNonce(); + + return ""; + //"; + } + + public static function cspNonceAttr() + { + if (!config('app.csp_enabled')) { + return ''; + } + + return ' nonce="'.\Helper::cspNonce().'"'; + } + + public static function isChatModeAvailable() + { + return count(CustomerChannel::getChannels()); + } + + public static function isChatMode() + { + return (int)\Session::get('chat_mode', 0); + } + + public static function setChatMode($is_on) + { + if ((int)$is_on) { + \Session::put('chat_mode', 1); + } else { + \Session::forget('chat_mode'); + } + } + + public static function detectCloudFlare() + { + if (!empty($_SERVER['HTTP_CF_IPCOUNTRY']) + || !empty($_SERVER['HTTP_CF_CONNECTING_IP']) + || !empty($_SERVER['HTTP_CF_VISITOR']) + || !empty($_SERVER['HTTP_CF_RAY']) + || ($_SERVER['HTTP_CDN_LOOP'] ?? '') == 'cloudflare' + ) { + return true; + } else { + return false; + } + } + + // Correct format: 2023-12-14 19:21 + // Datepicker with enableTime option enabled + // may return value in different format on iOS Safari: 2023-12-14T11:25 + public static function sanitizeDatepickerDatetime($datetime) + { + return str_replace('T', ' ', $datetime); + } +} diff --git a/freescout-dist/app/Misc/Mail.php b/freescout-dist/app/Misc/Mail.php new file mode 100644 index 0000000..56b05a6 --- /dev/null +++ b/freescout-dist/app/Misc/Mail.php @@ -0,0 +1,1044 @@ + to avoid false positives. + * Regex separators has "regex:" in the beginning. + */ + public static $alternative_reply_separators = [ + self::REPLY_SEPARATOR_HTML, // Our HTML separator + self::REPLY_SEPARATOR_TEXT, // Our plain text separator + + // Email service providers specific separators. + '', // Gmail + '', // Outlook / Live / Hotmail / Microsoft + '[^<]*/', // MS Outlook + + // General separators. + 'regex:/
])*>/', // General sepator. Should skip Gmail's. + '', + '‐‐‐‐‐‐‐ Original Message ‐‐‐‐‐‐‐', + '--------------- Original Message ---------------', + '-------- Αρχικό μήνυμα --------', // Greek + ]; + + /** + * md5 of the last applied mail config. + */ + public static $last_mail_config_hash = ''; + + /** + * Used to get SMTP queue id when sending emails to customers. + */ + public static $smtp_queue_id_plugin_registered = false; + + /** + * Configure mail sending parameters. + * + * @param App\Mailbox $mailbox + * @param App\User $user_from + * @param App\Conversation $conversation + */ + public static function setMailDriver($mailbox = null, $user_from = null, $conversation = null) + { + if ($mailbox) { + // Configure mail driver according to Mailbox settings + \Config::set('mail.driver', $mailbox->getMailDriverName()); + \Config::set('mail.from', $mailbox->getMailFrom($user_from, $conversation)); + + // SMTP + if ($mailbox->out_method == Mailbox::OUT_METHOD_SMTP) { + \Config::set('mail.host', $mailbox->out_server); + \Config::set('mail.port', $mailbox->out_port); + if (!$mailbox->out_username) { + \Config::set('mail.username', null); + \Config::set('mail.password', null); + } else { + \Config::set('mail.username', $mailbox->out_username); + \Config::set('mail.password', $mailbox->out_password); + } + \Config::set('mail.encryption', $mailbox->getOutEncryptionName()); + } + } else { + // Use default settings + \Config::set('mail.driver', \Config::get('mail.driver')); + \Config::set('mail.from', ['address' => self::getSystemMailFrom(), 'name' => '']); + } + + self::reapplyMailConfig(); + } + + /** + * Reapply new mail config. + */ + public static function reapplyMailConfig() + { + // Check hash to avoid recreating MailServiceProvider. + $mail_config_hash = md5(json_encode(\Config::get('mail'))); + + if (self::$last_mail_config_hash != $mail_config_hash) { + self::$last_mail_config_hash = $mail_config_hash; + } else { + return false; + } + + // Without doing this, Swift mailer uses old config values + // if there were emails sent with previous config. + \App::forgetInstance('mailer'); + \App::forgetInstance('swift.mailer'); + \App::forgetInstance('swift.transport'); + + (new \Illuminate\Mail\MailServiceProvider(app()))->register(); + // We have to update Mailer facade manually, as it does not happen automatically + // and previous instance of app('mailer') is used. + \Mail::swap(app('mailer')); + + \Eventy::action('mail.reapply_mail_config'); + } + + /** + * Set system mail driver for sending system emails to users. + * + * @param App\Mailbox $mailbox + */ + public static function setSystemMailDriver() + { + \Config::set('mail.driver', self::getSystemMailDriver()); + \Config::set('mail.from', [ + 'address' => self::getSystemMailFrom(), + 'name' => Option::get('company_name', \Config::get('app.name')), + ]); + + // SMTP + if (\Config::get('mail.driver') == self::MAIL_DRIVER_SMTP) { + \Config::set('mail.host', Option::get('mail_host')); + \Config::set('mail.port', Option::get('mail_port')); + if (!Option::get('mail_username')) { + \Config::set('mail.username', null); + \Config::set('mail.password', null); + } else { + \Config::set('mail.username', Option::get('mail_username')); + \Config::set('mail.password', \Helper::decrypt(Option::get('mail_password'))); + } + \Config::set('mail.encryption', Option::get('mail_encryption')); + } + + self::reapplyMailConfig(); + } + + /** + * Replace mail vars in the text. + */ + public static function replaceMailVars($text, $data = [], $escape = false, $remove_non_replaced = false) + { + // Available variables to insert into email in UI. + $vars = []; + + if (!empty($data['conversation'])) { + $vars['{%subject%}'] = $data['conversation']->subject; + $vars['{%conversation.number%}'] = $data['conversation']->number; + $vars['{%customer.email%}'] = $data['conversation']->customer_email; + } + if (!empty($data['mailbox'])) { + $vars['{%mailbox.email%}'] = $data['mailbox']->email; + $vars['{%mailbox.name%}'] = $data['mailbox']->name; + // To avoid recursion. + if (isset($data['mailbox_from_name'])) { + $vars['{%mailbox.fromName%}'] = $data['mailbox_from_name']; + } else { + $vars['{%mailbox.fromName%}'] = $data['mailbox']->getMailFrom(!empty($data['user']) ? $data['user'] : null)['name']; + } + } + if (!empty($data['customer'])) { + $vars['{%customer.fullName%}'] = $data['customer']->getFullName(true); + $vars['{%customer.firstName%}'] = $data['customer']->getFirstName(true); + $vars['{%customer.lastName%}'] = $data['customer']->last_name; + $vars['{%customer.company%}'] = $data['customer']->company; + } + if (!empty($data['user'])) { + $vars['{%user.fullName%}'] = $data['user']->getFullName(); + $vars['{%user.firstName%}'] = $data['user']->getFirstName(); + $vars['{%user.phone%}'] = $data['user']->phone; + $vars['{%user.email%}'] = $data['user']->email; + $vars['{%user.jobTitle%}'] = $data['user']->job_title; + $vars['{%user.lastName%}'] = $data['user']->last_name; + $vars['{%user.photoUrl%}'] = $data['user']->getPhotoUrl(); + } + + $vars = \Eventy::filter('mail_vars.replace', $vars, $data); + + if ($escape) { + foreach ($vars as $i => $var) { + $vars[$i] = htmlspecialchars($var ?? ''); + $vars[$i] = nl2br($vars[$i]); + } + } else { + foreach ($vars as $i => $var) { + $vars[$i] = nl2br($var ?? ''); + } + } + + $result = strtr($text, $vars); + + // Remove non-replaced placeholders. + if ($remove_non_replaced) { + $result = preg_replace('#\{%[^\.%\}]+\.[^%\}]+%\}#', '', $result ?? ''); + $result = trim($result); + } + + return $result; + } + + /** + * Check if text has vars in it. + */ + public static function hasVars($text) + { + return preg_match('/({%|%})/', $text ?? ''); + } + + /** + * Remove email from a list of emails. + */ + public static function removeEmailFromArray($list, $email) + { + return array_diff($list, [$email]); + } + + /** + * From address for sending system emails. + */ + public static function getSystemMailFrom() + { + $mail_from = Option::get('mail_from'); + if (!$mail_from) { + $mail_from = 'freescout@'.\Helper::getDomain(); + } + + return $mail_from; + } + + /** + * Mail driver for sending system emails. + */ + public static function getSystemMailDriver() + { + return Option::get('mail_driver', 'mail'); + } + + /** + * Send test email from mailbox. + */ + public static function sendTestMail($to, $mailbox = null) + { + if ($mailbox) { + // Configure mail driver according to Mailbox settings + \MailHelper::setMailDriver($mailbox); + + $status_message = ''; + + try { + \Mail::to([$to])->send(new \App\Mail\Test($mailbox)); + } catch (\Exception $e) { + // We come here in case SMTP server unavailable for example + $status_message = $e->getMessage(); + } + } else { + // System email + \MailHelper::setSystemMailDriver(); + + $status_message = ''; + + try { + \Mail::to([['name' => '', 'email' => $to]]) + ->send(new \App\Mail\Test()); + } catch (\Exception $e) { + // We come here in case SMTP server unavailable for example + $status_message = $e->getMessage(); + } + } + + if (\Mail::failures() || $status_message) { + SendLog::log(null, null, $to, SendLog::MAIL_TYPE_TEST, SendLog::STATUS_SEND_ERROR, null, null, $status_message); + if ($status_message) { + throw new \Exception($status_message, 1); + } else { + return false; + } + } else { + SendLog::log(null, null, $to, SendLog::MAIL_TYPE_TEST, SendLog::STATUS_ACCEPTED); + + return true; + } + } + + /** + * Check POP3/IMAP connection to the mailbox. + */ + public static function fetchTest($mailbox) + { + $client = \MailHelper::getMailboxClient($mailbox); + + // Connect to the Server + $client->connect(); + + // Get folder + $folder = $client->getFolder('INBOX'); + + if (!$folder) { + throw new \Exception('Could not get mailbox folder: INBOX', 1); + } + // Get unseen messages for a period + $messages = $folder->query()->unseen()->since(now()->subDays(1))->leaveUnread()->get(); + + $last_error = ''; + if (method_exists($client, 'getLastError')) { + $last_error = $client->getLastError(); + } + + if ($last_error && stristr($last_error, 'The specified charset is not supported')) { + // Solution for MS mailboxes. + // https://github.com/freescout-helpdesk/freescout/issues/176 + $messages = $folder->query()->unseen()->since(now()->subDays(1))->leaveUnread()->setCharset(null)->get(); + if (count($client->getErrors()) > 1) { + $last_error = $client->getLastError(); + } else { + $last_error = null; + } + } + + if ($last_error) { + throw new \Exception($last_error, 1); + } else { + return true; + } + } + + /** + * Convert list of emails to array. + * + * @return array + */ + public static function sanitizeEmails($emails) + { + $emails_array = []; + + if (is_array($emails)) { + $emails_array = $emails; + } else { + $emails_array = explode(',', $emails ?? ''); + } + + foreach ($emails_array as $i => $email) { + $emails_array[$i] = \App\Email::sanitizeEmail($email); + if (!$emails_array[$i]) { + unset($emails_array[$i]); + } + } + + return $emails_array; + } + + /** + * Check if email format is valid. + * + * @param [type] $email [description] + * + * @return [type] [description] + */ + public static function validateEmail($email) + { + return filter_var($email, FILTER_VALIDATE_EMAIL); + } + + /** + * Send system alert to super admin. + */ + public static function sendAlertMail($text, $title = '') + { + \App\Jobs\SendAlert::dispatch($text, $title)->onQueue('emails'); + } + + /** + * Send email to developers team. + */ + public static function sendEmailToDevs($subject, $body, $attachments = [], $from_user = null) + { + // Configure mail driver according to Mailbox settings + \MailHelper::setSystemMailDriver(); + + $status_message = ''; + + try { + \Mail::raw($body, function ($message) use ($subject, $attachments, $from_user) { + $message + ->subject($subject) + ->to(\Config::get('app.freescout_email')); + if ($attachments) { + foreach ($attachments as $attachment) { + $message->attach($attachment); + } + } + // Set user as Reply-To + if ($from_user) { + $message->replyTo($from_user->email, $from_user->getFullName()); + } + }); + } catch (\Exception $e) { + \Log::error(\Helper::formatException($e)); + // We come here in case SMTP server unavailable for example + return false; + } + + if (\Mail::failures()) { + return false; + } else { + return true; + } + } + + /** + * Get email marker for the outgoing email to track replies + * in case Message-ID header is removed by mail service provider. + * + * @param [type] $message_id [description] + * + * @return [type] [description] + */ + public static function getMessageMarker($message_id) + { + // It has to be BASE64, as Gmail converts it into link. + return '{#FS:'.base64_encode($message_id).'#}'; + } + + /** + * Fetch Message-ID from incoming email body. + * + * @param [type] $message_id [description] + * + * @return [type] [description] + */ + public static function fetchMessageMarkerValue($body) + { + preg_match('/{#FS:([^#]+)#}/', $body ?? '', $matches); + if (!empty($matches[1]) && base64_decode($matches[1])) { + // Return first found marker. + return base64_decode($matches[1]); + } + + return ''; + } + + public static function getMessageIdHash($thread_id) + { + return substr(md5($thread_id.config('app.key')), 0, 16); + } + + /** + * Detect autoresponder by headers. + * https://github.com/jpmckinney/multi_mail/wiki/Detecting-autoresponders + * https://www.jitbit.com/maxblog/18-detecting-outlook-autoreplyout-of-office-emails-and-x-auto-response-suppress-header/. + * + * @return bool [description] + */ + public static function isAutoResponder($headers_str) + { + $autoresponder_headers = [ + 'x-autoreply' => '', + 'x-autorespond' => '', + 'auto-submitted' => '', // this can be auto-replied, auto-generated, etc. + 'precedence' => ['auto_reply', 'bulk', 'junk'], + 'x-precedence' => ['auto_reply', 'bulk', 'junk'], + ]; + $headers = explode("\n", $headers_str ?? ''); + + foreach ($autoresponder_headers as $auto_header => $auto_header_value) { + foreach ($headers as $header) { + $parts = explode(':', $header, 2); + if (count($parts) == 2) { + $name = trim(strtolower($parts[0])); + $value = trim($parts[1]); + } else { + continue; + } + if (strtolower($name) == $auto_header) { + if (!$auto_header_value) { + return true; + } elseif (is_array($auto_header_value)) { + foreach ($auto_header_value as $auto_header_value_item) { + if ($value == $auto_header_value_item) { + return true; + } + } + } elseif ($value == $auto_header_value) { + return true; + } + } + } + } + + return false; + } + + /** + * Check Content-Type header. + * This is not 100% reliable, detects only standard DSN bounces. + * + * @param [type] $headers [description] + * + * @return [type] [description] + */ + public static function detectBounceByHeaders($headers) + { + if (preg_match("/Content-Type:((?:[^\n]|\n[\t ])+)(?:\n[^\t ]|$)/i", $headers, $match) + && preg_match("/multipart\/report/i", $match[1]) + && preg_match("/report-type=[\"']?delivery-status[\"']?/i", $match[1]) + ) { + return true; + } else { + return false; + } + } + + /** + * Parse email headers. + * + * @param [type] $headers_str [description] + * + * @return [type] [description] + */ + public static function parseHeaders($headers_str) + { + try { + return imap_rfc822_parse_headers($headers_str); + } catch (\Exception $e) { + return; + } + } + + public static function getHeader($headers_str, $header) + { + $headers = self::parseHeaders($headers_str); + if (!$headers) { + return; + } + $value = null; + if (property_exists($headers, $header)) { + $value = $headers->$header; + } else { + return; + } + switch ($header) { + case 'message_id': + $value = str_replace(['<', '>'], '', $value); + break; + } + + return $value; + } + + /** + * Get client for fetching emails. + */ + public static function getMailboxClient($mailbox) + { + $oauth = $mailbox->oauthEnabled(); + $new_library = config('app.new_fetching_library'); + + if (!$oauth && !$new_library) { + return new \Webklex\IMAP\Client([ + 'host' => $mailbox->in_server, + 'port' => $mailbox->in_port, + 'encryption' => $mailbox->getInEncryptionName(), + 'validate_cert' => $mailbox->in_validate_cert, + 'username' => $mailbox->in_username, + 'password' => $mailbox->in_password, + 'protocol' => $mailbox->getInProtocolName(), + ]); + } else { + + if ($oauth) { + \Config::set('imap.accounts.default', [ + 'host' => $mailbox->in_server, + 'port' => $mailbox->in_port, + 'encryption' => $mailbox->getInEncryptionName(), + 'validate_cert' => $mailbox->in_validate_cert, + 'username' => $mailbox->email, + 'password' => $mailbox->oauthGetParam('a_token'), + 'protocol' => $mailbox->getInProtocolName(), + 'authentication' => 'oauth', + ]); + } else { + \Config::set('imap.accounts.default', [ + 'host' => $mailbox->in_server, + 'port' => $mailbox->in_port, + 'encryption' => $mailbox->getInEncryptionName(), + 'validate_cert' => $mailbox->in_validate_cert, + // 'username' => $mailbox->email, + // 'password' => $mailbox->oauthGetParam('a_token'), + // 'protocol' => $mailbox->getInProtocolName(), + // 'authentication' => 'oauth', + 'username' => $mailbox->in_username, + 'password' => $mailbox->in_password, + 'protocol' => $mailbox->getInProtocolName(), + ]); + } + // To enable debug: /vendor/webklex/php-imap/src/Connection/Protocols + // Debug in console + if (app()->runningInConsole()) { + \Config::set('imap.options.debug', config('app.debug')); + } + + $cm = new \Webklex\PHPIMAP\ClientManager(config('imap')); + + // Refresh Access Token. + if ($oauth) { + if ((strtotime($mailbox->oauthGetParam('issued_on')) + (int)$mailbox->oauthGetParam('expires_in')) < time()) { + // Try to get an access token (using the authorization code grant) + $token_data = \MailHelper::oauthGetAccessToken(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [ + 'client_id' => $mailbox->in_username, + 'client_secret' => $mailbox->in_password, + 'refresh_token' => $mailbox->oauthGetParam('r_token'), + ]); + + if (!empty($token_data['a_token'])) { + $mailbox->setMetaParam('oauth', $token_data, true); + } elseif (!empty($token_data['error'])) { + $error_message = 'Error occurred refreshing oAuth Access Token: '.$token_data['error']; + \Helper::log(\App\ActivityLog::NAME_EMAILS_FETCHING, + \App\ActivityLog::DESCRIPTION_EMAILS_FETCHING_ERROR, [ + 'error' => $error_message, + 'mailbox' => $mailbox->name, + ]); + throw new \Exception($error_message, 1); + } + } + } + + // This makes it authenticate two times. + //$cm->setTimeout(60); + + return $cm->account('default'); + } + } + + /** + * Generate artificial Message-ID. + */ + public static function generateMessageId($email_address, $raw_body = '') + { + $hash = str_random(16); + if ($raw_body) { + $hash = md5(strval($raw_body)); + } + + return 'fs-'.$hash.'@'.preg_replace("/.*@/", '', $email_address); + } + + /** + * Fetch IMAP message by Message-ID. + */ + public static function fetchMessage($mailbox, $message_id, $message_date = null) + { + $no_charset = false; + + if (!$message_id) { + return null; + } + + try { + $client = \MailHelper::getMailboxClient($mailbox); + $client->connect(); + } catch (\Exception $e) { + \Helper::logException($e, '('.$mailbox->name.') Could not fetch specific message by Message-ID via IMAP:'); + return null; + } + + $imap_folders = \Eventy::filter('mail.fetch_message.imap_folders', $mailbox->getInImapFolders(), $mailbox); + + foreach ($imap_folders as $folder_name) { + try { + $folder = self::getImapFolder($client, $folder_name); + // Message-ID: <123@123.com> + $query = $folder->query() + ->text('<'.$message_id.'>') + ->leaveUnread() + ->limit(1); + + // Limit using date to speed up the search. + if ($message_date) { + $query->since($message_date->subDays(7)); + // Here we should add 14 days, as previous line subtracts 7 days. + $query->before($message_date->addDays(14)); + } + + if ($no_charset) { + $query->setCharset(null); + } + + $messages = $query->get(); + + $last_error = ''; + if (method_exists($client, 'getLastError')) { + $last_error = $client->getLastError(); + } + + if ($last_error && stristr($last_error, 'The specified charset is not supported')) { + // Solution for MS mailboxes. + // https://github.com/freescout-helpdesk/freescout/issues/176 + $query = $folder->query()->text('<'.$message_id.'>')->leaveUnread()->limit(1)->setCharset(null); + if ($message_date) { + $query->since($message_date->subDays(7)); + $query->before($message_date->addDays(7)); + } + $messages = $query->get(); + $no_charset = true; + } + + if (count($messages)) { + return $messages->first(); + } + + } catch (\Exception $e) { + \Helper::logException($e, '('.$mailbox->name.') Could not fetch specific message by Message-ID via IMAP:'); + } + } + + return null; + } + + public static function oauthGetAuthorizationUrl($provider_code, $params) + { + $args = []; + + switch ($provider_code) { + case self::OAUTH_PROVIDER_MICROSOFT: + // https://docs.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth + $args = [ + 'scope' => 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send', + 'response_type' => 'code', + 'approval_prompt' => 'auto', + 'redirect_uri' => route('mailboxes.oauth_callback'), + ]; + $args = array_merge($args, $params); + $url = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?'.http_build_query($args); + break; + } + + return $url; + } + + public static function oauthGetAccessToken($provider_code, $params) + { + $token_data = []; + $post_params = []; + + switch ($provider_code) { + case self::OAUTH_PROVIDER_MICROSOFT: + $post_params = [ + 'scope' => 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send', + "grant_type" => "authorization_code", + 'redirect_uri' => route('mailboxes.oauth_callback'), + ]; + + $post_params = array_merge($post_params, $params); + + // Refreshing Access Token. + if (!empty($post_params['refresh_token'])) { + $post_params['grant_type'] = 'refresh_token'; + } + + // $postUrl = "/common/oauth2/token"; + // $hostname = "login.microsoftonline.com"; + $full_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; + + // $headers = array( + // // "POST " . $postUrl . " HTTP/1.1", + // // "Host: login.windows.net", + // "Content-type: application/x-www-form-urlencoded", + // ); + + $curl = curl_init($full_url); + + curl_setopt($curl, CURLOPT_POST, true); + //curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + curl_setopt($curl, CURLOPT_POSTFIELDS, $post_params); + curl_setopt($curl, CURLOPT_HTTPHEADER, array("application/x-www-form-urlencoded")); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); + \Helper::setCurlDefaultOptions($curl); + curl_setopt($curl, CURLOPT_TIMEOUT, 180); + + $response = curl_exec($curl); + + if ($response) { + $result = json_decode($response, true); + + // [token_type] => Bearer + // [scope] => IMAP.AccessAsUser.All offline_access SMTP.Send User.Read + // [expires_in] => 4514 + // [ext_expires_in] => 4514 + // [expires_on] => 1646122657 + // [not_before] => 1646117842 + // [resource] => 00000002-0000-0000-c000-000000000000 + // [access_token] => dd + // [refresh_token] => dd + // [id_token] => dd + if (!empty($result['access_token'])) { + $token_data['provider'] = self::OAUTH_PROVIDER_MICROSOFT; + $token_data['a_token'] = $result['access_token']; + $token_data['r_token'] = $result['refresh_token']; + //$token_data['id_token'] = $result['id_token']; + $token_data['issued_on'] = now()->toDateTimeString(); + $token_data['expires_in'] = $result['expires_in']; + } elseif ($response) { + $token_data['error'] = $response; + } else { + $token_data['error'] = 'Response code: '.curl_getinfo($curl, CURLINFO_HTTP_CODE); + } + } + curl_close($curl); + + break; + } + + return $token_data; + } + + public static function oauthDisconnect($provider_code, $redirect_uri) + { + switch ($provider_code) { + case self::OAUTH_PROVIDER_MICROSOFT: + return redirect()->away('https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri='.urlencode($redirect_uri)); + break; + } + } + + public static function prepareMailable($mailable) + { + $custom_headers_str = config('app.custom_mail_headers'); + + if (empty($custom_headers_str)) { + return; + } + + $custom_headers = explode(';', $custom_headers_str); + + $mailable->withSwiftMessage(function ($swiftmessage) use ($custom_headers) { + $headers = $swiftmessage->getHeaders(); + + foreach ($custom_headers as $custom_header) { + $header_parts = explode(':', $custom_header); + + $header_name = trim($header_parts[0] ?? ''); + $header_value = trim($header_parts[1] ?? ''); + if ($header_name && $header_value) { + $headers->addTextHeader($header_name, $header_value); + } + } + return $swiftmessage; + }); + } + + public static function getImapFolder($client, $folder_name) + { + // https://github.com/freescout-helpdesk/freescout/issues/3502 + $folder_name = mb_convert_encoding($folder_name, "UTF7-IMAP","UTF-8"); + + if (method_exists($client, 'getFolderByPath')) { + return $client->getFolderByPath($folder_name); + } else { + return $client->getFolder($folder_name); + } + } + + /** + * This function is used to decode email subjects and attachment names in Webklex libraries. + */ + public static function decodeSubject($subject) + { + // Remove new lines as iconv_mime_decode() may loose a part separated by new line: + // =?utf-8?Q?Gesch=C3=A4ftskonto?= erstellen =?utf-8?Q?f=C3=BCr?= + // 249143 + $subject = preg_replace("/[\r\n]/", '', $subject); + // https://github.com/freescout-helpdesk/freescout/issues/3185 + $subject = str_ireplace('=?iso-2022-jp?', '=?iso-2022-jp-ms?', $subject); + + // Sometimes imap_utf8() can't decode the subject, for example: + // =?iso-2022-jp?B?GyRCIXlCaBsoQjEzMhskQjlmISEhViUsITwlRyVzGyhCJhskQiUoJS8lOSVGJWolIiFXQGxMZ0U5JE4kPyRhJE4jURsoQiYbJEIjQSU1JW0lcyEhIVo3bjQpJSglLyU5JUYlaiUiISYlbyE8JS8hWxsoQg==?= + // and sometimes iconv_mime_decode() can't decode the subject. + // So we are using both. + // + // We are trying iconv_mime_decode() first because imap_utf8() + // decodes umlauts into two symbols: + // https://github.com/freescout-helpdesk/freescout/issues/2965 + + // Sometimes subject is split into parts and each part is base63 encoded. + // And sometimes it's first encoded and after that split. + // https://github.com/freescout-helpdesk/freescout/issues/3066 + + // Step 1. Abnormal way - text is encoded and split into parts. + + // Only one type of encoding should be used. + preg_match_all("/(=\?[^\?]+\?[BQ]\?)([^\?]+)(\?=)/i", $subject, $m); + $encodings = $m[1] ?? []; + array_walk($encodings, function($value) { + $value = strtolower($value); + }); + $one_encoding = count(array_unique($encodings)) == 1; + + if ($one_encoding) { + // First try to join all lines and parts. + // Keep in mind that there can be non-encoded parts also: + // =?utf-8?Q?Gesch=C3=A4ftskonto?= erstellen =?utf-8?Q?f=C3=BCr?= + preg_match_all("/(=\?[^\?]+\?[BQ]\?)([^\?]+)(\?=)[\r\n\t ]*/i", $subject, $m); + + $joined_parts = ''; + if (count($m[1]) > 1 && !empty($m[2]) && !preg_match("/[\r\n\t ]+[^=]/i", $subject)) { + // Example: GyRCQGlNVTtZRTkhIT4uTlMbKEI= + $joined_parts = $m[1][0].implode('', $m[2]).$m[3][0]; + + // Base64 and URL encoded string can't contain "=" in the middle + // https://stackoverflow.com/questions/6916805/why-does-a-base64-encoded-string-have-an-sign-at-the-end + $has_equal_in_the_middle = preg_match("#=+([^$\? =])#", $joined_parts); + + if (!$has_equal_in_the_middle) { + $subject_decoded = iconv_mime_decode($joined_parts, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8"); + + if ($subject_decoded + && trim($subject_decoded) != trim($joined_parts) + && trim($subject_decoded) != trim(rtrim($joined_parts, '=')) + && !self::isNotYetFullyDecoded($subject_decoded) + ) { + return $subject_decoded; + } + + // Try imap_utf8(). + // =?iso-2022-jp?B?IBskQiFaSEcyPDpuQ?= =?iso-2022-jp?B?C4wTU1qIVs3Mkp2JSIlLyU3JSItahsoQg==?= + $subject_decoded = \imap_utf8($joined_parts); + + if ($subject_decoded + && trim($subject_decoded) != trim($joined_parts) + && trim($subject_decoded) != trim(rtrim($joined_parts, '=')) + && !self::isNotYetFullyDecoded($subject_decoded) + ) { + return $subject_decoded; + } + } + } + } + + // Step 2. Standard way - each part is encoded separately. + + // iconv_mime_decode() can't decode: + // =?iso-2022-jp?B?IBskQiFaSEcyPDpuQC4wTU1qIVs3Mkp2JSIlLyU3JSItahsoQg==?= + $subject_decoded = iconv_mime_decode($subject, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8"); + + // Sometimes iconv_mime_decode() can't decode some parts of the subject: + // =?iso-2022-jp?B?IBskQiFaSEcyPDpuQC4wTU1qIVs3Mkp2JSIlLyU3JSItahsoQg==?= + // =?iso-2022-jp?B?GyRCQGlNVTtZRTkhIT4uTlMbKEI=?= + if (self::isNotYetFullyDecoded($subject_decoded)) { + $subject_decoded = \imap_utf8($subject); + } + + // All previous functions could not decode text. + // mb_decode_mimeheader() properly decodes umlauts into one unice symbol. + // But we use mb_decode_mimeheader() as a last resort as it may garble some symbols. + // Example: =?ISO-8859-1?Q?Vorgang 538336029: M=F6chten Sie Ihre E-Mail-Adresse =E4ndern??= + if (self::isNotYetFullyDecoded($subject_decoded)) { + $subject_decoded = mb_decode_mimeheader($subject); + } + + if (!$subject_decoded) { + $subject_decoded = $subject; + } + + return $subject_decoded; + } + + public static function isNotYetFullyDecoded($subject_decoded) { + // https://stackoverflow.com/questions/15276191/why-does-a-diamond-with-a-questionmark-in-it-appear-in-my-html + $invalid_utf_symbols = ['�']; + + return preg_match_all("/=\?[^\?]+\?[BQ]\?/i", $subject_decoded) + || !mb_check_encoding($subject_decoded, 'UTF-8') + || \Str::contains($subject_decoded, $invalid_utf_symbols); + } + + // public static function oauthGetProvider($provider_code, $params) + // { + // $provider = null; + + // switch ($provider_code) { + // case self::OAUTH_PROVIDER_MICROSOFT: + // $provider = new \Stevenmaguire\OAuth2\Client\Provider\Microsoft([ + // // Required + // 'clientId' => $params['client_id'], + // 'clientSecret' => $params['client_secret'], + // 'redirectUri' => route('mailboxes.oauth_callback'), + // //https://login.microsoftonline.com/common/oauth2/authorize'; + // 'urlAuthorize' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + // 'urlAccessToken' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + // 'urlResourceOwnerDetails' => 'https://outlook.office.com/api/v1.0/me' + // ]); + // break; + // } + + // return $provider; + // } +} diff --git a/freescout-dist/app/Misc/SwiftGetSmtpQueueId.php b/freescout-dist/app/Misc/SwiftGetSmtpQueueId.php new file mode 100644 index 0000000..8ac5b11 --- /dev/null +++ b/freescout-dist/app/Misc/SwiftGetSmtpQueueId.php @@ -0,0 +1,19 @@ +getResponse(); + if (strpos($response_text, 'queued') !== false) { + preg_match("#queued as ([^\$\r\n ]+)[$\r\n]#", $response_text, $m); + if (!empty($m[1]) && trim($m[1])) { + self::$last_smtp_queue_id = trim($m[1]); + } + } + } +} \ No newline at end of file diff --git a/freescout-dist/app/Misc/WpApi.php b/freescout-dist/app/Misc/WpApi.php new file mode 100644 index 0000000..be289c2 --- /dev/null +++ b/freescout-dist/app/Misc/WpApi.php @@ -0,0 +1,176 @@ +request('POST', $url, \Helper::setGuzzleDefaultOptions([ + 'connect_timeout' => 10, + 'form_params' => $params, + ])); + } else { + $params['v'] = config('app.version'); + return $client->request('GET', $url, \Helper::setGuzzleDefaultOptions([ + 'connect_timeout' => 10, + 'query' => $params, + ])); + } + } + + /** + * API request. + */ + public static function request($method, $endpoint, $params = [], $alternative_api = false) + { + self::$lastError = null; + + try { + $response = self::httpRequest($method, self::url($endpoint, $alternative_api), $params); + } catch (\Exception $e) { + if (!$alternative_api) { + return self::request($method, $endpoint, $params, true); + } + \Helper::logException($e, 'WpApi'); + self::$lastError = [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ]; + + return []; + } + + // https://guzzle3.readthedocs.io/http-client/response.html + if ($response->getStatusCode() < 500) { + $json = \Helper::jsonToArray($response->getBody()); + + if (!empty($json['code']) && !empty($json['message']) && + !empty($json['data']) && !empty($json['data']['status']) && $json['data']['status'] != 200 + ) { + self::$lastError = $json; + // Maybe log error here + return []; + } else { + return $json; + } + } else { + return []; + } + } + + /** + * Get modules. + */ + public static function getModules() + { + return self::request(self::METHOD_GET, self::ENDPOINT_MODULES); + } + + /** + * Check module license. + */ + public static function checkLicense($params) + { + $params['action'] = self::ACTION_CHECK_LICENSE; + + $endpoint = self::ENDPOINT_MODULES; + + if (!empty($params['module_alias'])) { + $endpoint .= '/'.$params['module_alias']; + } + + return self::request(self::METHOD_POST, $endpoint, $params); + } + + /** + * Check module license. + */ + public static function checkLicenses($params) + { + $params['action'] = self::ACTION_CHECK_LICENSES; + + $endpoint = self::ENDPOINT_MODULES; + + return self::request(self::METHOD_POST, $endpoint, $params); + } + + /** + * Activate module license. + */ + public static function activateLicense($params) + { + $params['action'] = self::ACTION_ACTIVATE_LICENSE; + + $endpoint = self::ENDPOINT_MODULES; + + if (!empty($params['module_alias'])) { + $endpoint .= '/'.$params['module_alias']; + } + + return self::request(self::METHOD_POST, $endpoint, $params); + } + + /** + * Deactivate module license. + */ + public static function deactivateLicense($params) + { + $params['action'] = self::ACTION_DEACTIVATE_LICENSE; + + $endpoint = self::ENDPOINT_MODULES; + + if (!empty($params['module_alias'])) { + $endpoint .= '/'.$params['module_alias']; + } + + return self::request(self::METHOD_POST, $endpoint, $params); + } + + /** + * Get license details. + */ + public static function getVersion($params) + { + $params['action'] = self::ACTION_GET_VERSION; + + $endpoint = self::ENDPOINT_MODULES; + + if (!empty($params['module_alias'])) { + $endpoint .= '/'.$params['module_alias']; + } + + return self::request(self::METHOD_POST, $endpoint, $params); + } +} diff --git a/freescout-dist/app/Module.php b/freescout-dist/app/Module.php new file mode 100644 index 0000000..c6da4c4 --- /dev/null +++ b/freescout-dist/app/Module.php @@ -0,0 +1,544 @@ +active; + } else { + return false; + } + } + + public static function setActive($alias, $active, $save = true) + { + $module = self::getByAliasOrCreate($alias); + $module->active = $active; + if ($save) { + $module->save(); + } + + return true; + } + + /** + * Is module license activated. + */ + public static function isLicenseActivated($alias, $author_url) + { + // If module is from modules directory, license activation is required + if ($author_url && self::isOfficial($author_url)) { + $module = self::getByAlias($alias); + if ($module) { + return $module->activated; + } else { + return false; + } + } else { + return true; + } + } + + public static function isOfficial($author_url) + { + return parse_url($author_url ?? '', PHP_URL_HOST) == parse_url(\Config::get('app.freescout_url') ?? '', PHP_URL_HOST); + } + + /** + * Activate module license. + * + * @param [type] $alias [description] + * @param [type] $details_url [description] + * + * @return bool [description] + */ + public static function activateLicense($alias, $license) + { + $module = self::getByAliasOrCreate($alias); + $module->license = $license; + $module->activated = true; + $module->save(); + } + + public static function deactivateLicense($alias, $license) + { + $module = self::getByAliasOrCreate($alias); + $module->license = $license; + $module->activated = false; + $module->save(); + } + + public static function getByAliasOrCreate($alias) + { + $module = self::getByAlias($alias); + if (!$module) { + $module = new self(); + $module->alias = $alias; + } + + return $module; + } + + /** + * Get module license. + */ + public static function getLicense($alias) + { + $module = self::getByAlias($alias); + if ($module) { + return $module->license; + } else { + return ''; + } + } + + public static function setLicense($alias, $license) + { + $module = self::getByAliasOrCreate($alias); + $module->license = $license; + $module->save(); + } + + public static function normalizeAlias($alias) + { + return trim(strtolower($alias)); + } + + public static function getByAlias($alias) + { + $modules = self::getCached(); + if ($modules) { + return self::getCached()->where('alias', $alias)->first(); + } else { + return; + } + } + + /** + * Deactivate module and update modules cache. + */ + public static function deactiveModule($alias, $clear_app_cache = true) + { + self::setActive($alias, false); + // Update modules cache + \Module::clearCache(); + if ($clear_app_cache) { + \Artisan::call('freescout:clear-cache'); + } + } + + /** + * Get URL used to active and check license. + * + * @return [type] [description] + */ + public static function getAppUrl() + { + return parse_url(\Config::get('app.url'), PHP_URL_HOST); + } + + /** + * Check missing extensions among required by module. + * + * @param [type] $required_extensions [description] + * + * @return [type] [description] + */ + public static function getMissingExtensions($required_extensions) + { + $missing = []; + + $list = explode(',', $required_extensions ?? ''); + if (!is_array($list) || !count($list)) { + return []; + } + foreach ($list as $ext) { + $ext = trim($ext); + if ($ext && !extension_loaded($ext)) { + $missing[] = $ext; + } + } + + return $missing; + } + + /** + * Check missing modules required by the module. + */ + public static function getMissingModules($required_modules, $modules = []) + { + $missing = []; + + if (!$modules) { + $modules = \Module::all(); + } + + if (!is_array($required_modules) || !count($required_modules)) { + return []; + } + foreach ($required_modules as $alias => $version) { + $module = null; + foreach ($modules as $module_item) { + if ($module_item->alias == $alias) { + $module = $module_item; + } + } + if (!$module) { + $missing[$alias] = $version; + continue; + } + + if (!self::isActive($alias) || !version_compare($module->version, $version, '>=')) { + $missing[$alias] = $version; + } + } + + return $missing; + } + + public static function formatName($name) + { + return preg_replace("/ Module($|.*\[.*\]$)/", '$1', $name); + } + + public static function formatModuleData($module_data) + { + // Add (Third-Party). + if (\App\Module::isOfficial($module_data['authorUrl']) + && $module_data['author'] != 'FreeScout' + && mb_substr(trim($module_data['name']), -1) != ']' + ) { + $module_data['name'] = $module_data['name'].' ['.__('Third-Party').']'; + } + return $module_data; + } + + public static function isThirdParty($module_data) + { + if (\App\Module::isOfficial($module_data['authorUrl']) + && $module_data['author'] != 'FreeScout' + ) { + return true; + } else { + return false; + } + } + + public static function getSymlinkPath($alias) + { + return public_path().\Module::getPublicPath($alias); + } + + // Check and try to fix invalid or missing symlinks. + public static function checkSymlinks($module_aliases = null) + { + $invalid_symlinks = []; + + if ($module_aliases === null) { + // Get all active modules. + foreach (\Module::all() as $module) { + if ($module->active()) { + $module_aliases[] = $module->getAlias(); + } + } + } + if ($module_aliases && count($module_aliases)) { + foreach ($module_aliases as $module_alias) { + $from = self::getSymlinkPath($module_alias); + + $create = false; + + // file_exists() also checks if symlink target exists. + // file_exists() and is_dir() may throw "open_basedir restriction in effect". + try { + if (!file_exists($from) || !is_link($from)) { + if (is_dir($from)) { + @rename($from, $from.'_'.date('YmdHis')); + } else { + @unlink($from); + } + $create = true; + } + } catch (\Exception $e) { + $create = true; + } + + // Skip this check. + // elseif (is_link($from) && readlink($symlink_path) != '') { + // // Symlink leads to the wrong place. + // $create = true; + // } + + // Try to create the symlink. + if ($create) { + $to = self::createModuleSymlink($module_alias); + + if ($to && (!is_link($from) || is_link($to) || !file_exists($from))) { + $invalid_symlinks[$from] = $to; + } + } + } + } + + return $invalid_symlinks; + } + + // There is similar function in ModuleInstall.php + public static function createModuleSymlink($alias) + { + $from = self::getSymlinkPath($alias); + + $module = \Module::findByAlias($alias); + if (!$module) { + return false; + } + + $to = $module->getExtraPath('Public'); + + // file_exists() may throw "open_basedir restriction in effect". + try { + // If module's Public is symlink. + if (is_link($to)) { + @unlink($to); + } + + // Symlimk may exist but lead to the module folder in a wrong case. + // So we need first try to remove it. + if (!file_exists($from)) { + @unlink($from); + } + + if (file_exists($from)) { + return $to; + } + + if (!file_exists($to)) { + // Try to create Public folder. + try { + \File::makeDirectory($to, \Helper::DIR_PERMISSIONS); + } catch (\Exception $e) { + // If it's a broken symlink. + if (is_link($to)) { + @unlink($to); + } + } + } + + try { + symlink($to, $from); + } catch (\Exception $e) { + \Log::error('Error occurred creating ['.$from.' » '.$to.'] symlink: '.$e->getMessage()); + //return false; + } + } catch (\Exception $e) { + return false; + } + + return $to; + } + + public static function updateModule($alias) + { + $result = [ + 'status' => 'error', + // Error message. + 'msg' => '', + 'msg_success' => '', + 'download_error' => false, + // Error message with the link for downloading the module. + 'download_msg' => '', + 'output' => '', + 'module_name' => '', + ]; + + $module = \Module::findByAlias($alias); + + if (!$module) { + $result['msg'] = __('Module not found').': '.$alias; + } + + // Get module name. + $name = '?'; + if ($module) { + $name = $module->getName(); + $result['module_name'] = $name; + } + + // Download new version. + if (!$result['msg']) { + $params = [ + 'license' => self::getLicense($alias), + 'module_alias' => $alias, + 'url' => self::getAppUrl(), + ]; + $license_details = WpApi::getVersion($params); + + if (WpApi::$lastError) { + $result['msg'] = WpApi::$lastError['message']; + } elseif (!empty($license_details['code']) && !empty($license_details['message'])) { + $result['msg'] = $license_details['message']; + } elseif (!empty($license_details['required_app_version']) && !\Helper::checkAppVersion($license_details['required_app_version'])) { + $result['msg'] = 'Module requires app version:'.' '.$license_details['required_app_version']; + } elseif (!empty($license_details['download_link'])) { + // Download module. + $module_archive = \Module::getPath().DIRECTORY_SEPARATOR.$alias.'.zip'; + + try { + \Helper::downloadRemoteFile($license_details['download_link'], $module_archive); + } catch (\Exception $e) { + $result['msg'] = $e->getMessage(); + } + + if (!file_exists($module_archive)) { + $result['download_error'] = true; + } else { + // Extract. + try { + // Sometimes by some reason Public folder becomes a symlink leading to itself. + // It causes an error during updating process. + // https://github.com/freescout-helpdesk/freescout/issues/2709 + $public_folder = $module->getPath().DIRECTORY_SEPARATOR.'Public'; + try { + if (is_link($public_folder)) { + unlink($public_folder); + } + } catch (\Exception $e) { + // Do nothing. + } + + \Helper::unzip($module_archive, \Module::getPath()); + } catch (\Exception $e) { + $result['msg'] = $e->getMessage(); + } + // Check if extracted module exists. + \Module::clearCache(); + $module = \Module::findByAlias($alias); + if (!$module) { + $result['download_error'] = true; + } + } + + // Remove archive. + if (file_exists($module_archive)) { + \File::delete($module_archive); + } + + if ($result['download_error']) { + $result['download_msg'] = __('Error occurred downloading the module. Please :%a_being%download:%a_end% module manually and extract into :folder', ['%a_being%' => '', '%a_end%' => '', 'folder' => ''.\Module::getPath().'']); + } + } elseif ($license_details['status'] && $result['msg'] = self::getErrorMessage($license_details['status'])) { + //$result['msg'] = ; + } else { + $result['msg'] = __('Error occurred').': '.json_encode($license_details); + } + } + + // Run post-update instructions. + if (!$result['msg'] && !$result['download_error']) { + + $output_log = new BufferedOutput(); + \Artisan::call('freescout:module-install', ['module_alias' => $alias], $output_log); + $result['output'] = $output_log->fetch() ?: ' '; + + $result['msg'] = __('Error occurred activating ":name" module', ['name' => $name]); + + if (session('flashes_floating') && is_array(session('flashes_floating'))) { + // Error. + // If there was any error, module has been deactivated via modules.register_error filter + $result['msg'] = ''; + foreach (session('flashes_floating') as $flash) { + $result['msg'] .= $flash['text'].' '; + } + } elseif (strstr($result['output'], 'Configuration cached successfully')) { + // Success. + $result['status'] = 'success'; + $result['msg'] = ''; + $result['msg_success'] = __('":name" module successfully updated!', ['name' => $name]); + } else { + // Error. + // Deactivate module. + \App\Module::setActive($alias, false); + \Artisan::call('freescout:clear-cache'); + } + } + + return $result; + } + + public static function getErrorMessage($code, $result = null) + { + $msg = ''; + + switch ($code) { + case 'missing': + $msg = __('License key does not exist'); + break; + case 'license_not_activable': + $msg = __("You have to activate each bundle's module separately"); + break; + case 'disabled': + $msg = __('License key has been revoked'); + break; + case 'no_activations_left': + $msg = __('No activations left for this license key').' ('.__("Use 'Deactivate License' link above to transfer license key from another domain").')'; + break; + case 'expired': + $msg = __('License key has expired'); + break; + case 'key_mismatch': + $msg = __('License key belongs to another module'); + break; + // This also happens when entering a valid license key for wrong module. + case 'invalid_item_id': + $msg = __('Invalid license key'); + //$msg = __('Module not found in the modules directory'); + break; + case 'site_inactive': + $msg = __('License key is activated on another domain.').' '.__("Use 'Deactivate License' link above to transfer license key from another domain"); + //$msg = __('Module not found in the modules directory'); + break; + default: + if ($result && !empty($result['error'])) { + $msg = __('Error code:'.' '.$result['error']); + } + break; + } + + return $msg; + } +} diff --git a/freescout-dist/app/Notifications/BroadcastNotification.php b/freescout-dist/app/Notifications/BroadcastNotification.php new file mode 100644 index 0000000..45bd049 --- /dev/null +++ b/freescout-dist/app/Notifications/BroadcastNotification.php @@ -0,0 +1,127 @@ +conversation = $conversation; + $this->thread = $thread; + $this->mediums = $mediums; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $user + * + * @return array + */ + public function via($user) + { + return [\App\Channels\RealtimeBroadcastChannel::class]; + // Standard "broadcast" channel creates a queuable event which runs broadcast for the broadcaster. + //return ['broadcast']; + } + + /** + * Get the broadcastable representation of the notification. + * + * @param mixed $notifiable + * + * @return BroadcastMessage + */ + public function toBroadcast($user) + { + return new BroadcastMessage([ + 'thread_id' => $this->thread->id, + 'number' => $this->conversation->number, + 'mediums' => $this->mediums, + ]); + } + + public static function fetchPayloadData($payload) + { + $data = []; + + if (empty($payload->thread_id) || empty($payload->mediums)) { + return $data; + } + + // Try to convert to array. + $mediums = (array)$payload->mediums; + + $thread = Thread::find($payload->thread_id); + + if (empty($thread)) { + return $data; + } + + // Dummy DB notification to pass to the template + $db_notification = new \Illuminate\Notifications\DatabaseNotification(); + + // HTML for the menu notification (uses same medium as for email) + if (in_array(Subscription::MEDIUM_EMAIL, $mediums)) { + $web_notifications_info = []; + + // Get last reply or note of the conversation to display it's text + $last_thread_body = ''; + $last_thread = Thread::where('conversation_id', $thread->conversation_id) + // Select must contain all fields from orderBy() to avoid: + // General error: 3065 Expression #1 of ORDER BY clause is not in SELECT + ->select(['body', 'created_at']) + ->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_NOTE]) + ->orderBy('created_at') + ->first(); + + if ($last_thread) { + $last_thread_body = $last_thread->body; + } + + //$db_notification->id = 'dummy'; + $web_notifications_info['notification'] = $db_notification; + $web_notifications_info['created_at'] = \Carbon\Carbon::now(); + // ['notification']->read_at + // ['notification']->id + $web_notifications_info['conversation'] = $thread->conversation; + $web_notifications_info['thread'] = $thread; + $web_notifications_info['last_thread_body'] = $last_thread_body; + + $data['web']['html'] = view('users/partials/web_notifications', [ + 'web_notifications_info_data' => [$web_notifications_info], + ])->render(); + } + + // Text and url for the browser push notification + if (in_array(Subscription::MEDIUM_BROWSER, $mediums)) { + $data['browser']['text'] = strip_tags($thread->getActionDescription($thread->conversation->number)); + $data['browser']['url'] = $thread->conversation->url(null, $thread->id, ['mark_as_read' => $db_notification->id]); + } + + return $data; + } +} diff --git a/freescout-dist/app/Notifications/WebsiteNotification.php b/freescout-dist/app/Notifications/WebsiteNotification.php new file mode 100644 index 0000000..629b4ea --- /dev/null +++ b/freescout-dist/app/Notifications/WebsiteNotification.php @@ -0,0 +1,158 @@ +conversation = $conversation; + $this->thread = $thread; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $user + * + * @return array + */ + public function via($user) + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @param mixed $user + * + * @return array + */ + public function toArray($user) + { + return [ + 'thread_id' => $this->thread->id, + 'conversation_id' => $this->conversation->id, + ]; + } + + /** + * Fetch data from DB for notifications list to display it. + */ + public static function fetchNotificationsData($notifications) + { + $data = []; + + $threads = []; + //$conversations = []; + + //$conversation_ids = []; + $thread_ids = []; + + // Get threads with their customers and users + foreach ($notifications as $notification) { + if (!empty($notification->data['thread_id'])) { + $thread_ids[] = $notification->data['thread_id']; + } + } + if ($thread_ids) { + $threads = Thread::whereIn('id', $thread_ids) + ->with('conversation') + ->with('created_by_user') + ->with('created_by_customer') + ->with('user') + ->get(); + } + + // Get last reply or note of the conversation to display it's text + // if ($threads) { + // $last_threads = Thread::whereIn('conversation_id', $threads->pluck('conversation_id')->unique()->toArray()) + // // Select must contain all fields from orderBy() to avoid: + // // General error: 3065 Expression #1 of ORDER BY clause is not in SELECT + // ->select(['id', 'conversation_id', 'body', 'created_at']) + // ->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_NOTE]) + // ->distinct('conversation_id') + // ->orderBy('created_at', 'desc') + // // We can not use groupBy because of "isn't in GROUP BY" + // //->groupBy('conversation_id') + // ->get(); + // } + + // Populate all collected data into array + foreach ($notifications as $notification) { + $conversation_number = ''; + if (!empty($notification->data['number'])) { + $conversation_number = $notification->data['number']; + } + + $thread = null; + $user = null; + $created_by_user = null; + $created_by_customer = null; + + if (!empty($notification->data['thread_id'])) { + $thread = $threads->firstWhere('id', $notification->data['thread_id']); + if (empty($thread)) { + continue; + } + if ($thread->user_id) { + $user = $thread->user; + } + if ($thread->created_by_user_id) { + $created_by_user = $thread->created_by_user_id; + } + if ($thread->created_by_customer_id) { + $created_by_customer = $thread->created_by_customer_id; + } + } else { + continue; + } + + // $last_thread_body = ''; + // $conversation = null; + + // $last_thread = $last_threads->firstWhere('conversation_id', $thread->conversation_id); + // if ($last_thread) { + $last_thread_body = $thread->body; + $conversation = $thread->conversation; + //} + if (empty($conversation)) { + continue; + } + + $data[] = [ + 'notification' => $notification, + 'created_at' => $notification->created_at, + 'conversation' => $conversation, + 'thread' => $thread, + 'last_thread_body' => $last_thread_body, + 'user' => $user, + 'created_by_user' => $created_by_user, + 'created_by_customer' => $created_by_customer, + ]; + } + + return $data; + } +} diff --git a/freescout-dist/app/Observers/AttachmentObserver.php b/freescout-dist/app/Observers/AttachmentObserver.php new file mode 100644 index 0000000..36f7016 --- /dev/null +++ b/freescout-dist/app/Observers/AttachmentObserver.php @@ -0,0 +1,18 @@ +source_via == Conversation::PERSON_USER) { + $conversation->read_by_user = true; + } + } + + /** + * On create. + * + * @param Conversation $conversation + */ + public function created(Conversation $conversation) + { + // Better to do it manually + //$conversation->mailbox->updateFoldersCounters(); + } + + /** + * On conversation delete. + * + * @param Conversation $conversation + */ + public function deleting(Conversation $conversation) + { + $conversation->threads()->delete(); + $conversation->followers()->delete(); + + \Eventy::action('conversation.deleting', $conversation); + } + + public function updated(Conversation $conversation) + { + \Eventy::action('conversation.updated', $conversation); + } +} diff --git a/freescout-dist/app/Observers/CustomerObserver.php b/freescout-dist/app/Observers/CustomerObserver.php new file mode 100644 index 0000000..8edbd1e --- /dev/null +++ b/freescout-dist/app/Observers/CustomerObserver.php @@ -0,0 +1,33 @@ +getPhones(); + // Set numeric phones. + $customer->setPhones($phones); + } + + public function deleting(Customer $customer) + { + \Eventy::action('customer.deleting', $customer); + } + + public function created(Customer $customer) + { + if ($customer->channel && $customer->channel_id) { + CustomerChannel::create($customer->id, $customer->channel, $customer->channel_id); + } + } + + public function deleted(Customer $customer) + { + CustomerChannel::where('customer_id', $customer->id)->delete(); + } +} diff --git a/freescout-dist/app/Observers/DatabaseNotificationObserver.php b/freescout-dist/app/Observers/DatabaseNotificationObserver.php new file mode 100644 index 0000000..eca3bf7 --- /dev/null +++ b/freescout-dist/app/Observers/DatabaseNotificationObserver.php @@ -0,0 +1,18 @@ +notifiable->clearWebsiteNotificationsCache(); + } +} diff --git a/freescout-dist/app/Observers/EmailObserver.php b/freescout-dist/app/Observers/EmailObserver.php new file mode 100644 index 0000000..62414fb --- /dev/null +++ b/freescout-dist/app/Observers/EmailObserver.php @@ -0,0 +1,18 @@ +createPublicFolders(); + $mailbox->syncPersonalFolders(); + $mailbox->createAdminPersonalFolders(); + } + + /** + * Delete the following on mailbox delete: + * - folders + * - conversations + * - user permissions. + * + * @param Mailbox $mailbox + * + * @return [type] [description] + */ + public function deleting(Mailbox $mailbox) + { + $mailbox->users()->delete(); + $mailbox->conversations()->delete(); + $mailbox->folders()->delete(); + + \Eventy::action('mailbox.before_delete', $mailbox); + } +} diff --git a/freescout-dist/app/Observers/SendLogObserver.php b/freescout-dist/app/Observers/SendLogObserver.php new file mode 100644 index 0000000..8cf4f69 --- /dev/null +++ b/freescout-dist/app/Observers/SendLogObserver.php @@ -0,0 +1,20 @@ +thread_id && ($send_log->customer_id || ($send_log->user_id && $send_log->user_id == $send_log->thread->user_id))) { + $send_log->thread->send_status = $send_log->status; + $send_log->thread->save(); + } + } +} diff --git a/freescout-dist/app/Observers/ThreadObserver.php b/freescout-dist/app/Observers/ThreadObserver.php new file mode 100644 index 0000000..7bc6628 --- /dev/null +++ b/freescout-dist/app/Observers/ThreadObserver.php @@ -0,0 +1,98 @@ +conversation; + + if (!$conversation) { + return; + } + + // Fetch date & time setting. + $use_mail_date_on_fetching = config('app.use_mail_date_on_fetching'); + + if ($use_mail_date_on_fetching) { + $now = $thread->created_at; + }else{ + $now = date('Y-m-d H:i:s'); + } + + if (!in_array($thread->type, [Thread::TYPE_LINEITEM, Thread::TYPE_NOTE]) && $thread->state == Thread::STATE_PUBLISHED) { + $conversation->threads_count++; + } + if (!in_array($thread->type, [Thread::TYPE_CUSTOMER])) { + $conversation->user_updated_at = $now; + } + + if ((in_array($thread->type, [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE]) + || ($conversation->isPhone() && in_array($thread->type, [Thread::TYPE_NOTE]))) + && $thread->state == Thread::STATE_PUBLISHED + ) { + // $conversation->cc = $thread->cc; + // $conversation->bcc = $thread->bcc; + $conversation->last_reply_at = $now; + $conversation->last_reply_from = $thread->source_via; + } + if ($conversation->source_via == Conversation::PERSON_CUSTOMER) { + $conversation->read_by_user = false; + } + + // Update preview. + if (in_array($thread->type, [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_NOTE]) + && $thread->state == Thread::STATE_PUBLISHED + && !$thread->isForward() + // Otherwise preview is not set when conversation is created + // outside of the web interface. + //&& ($conversation->threads_count > 1 || $thread->type == Thread::TYPE_NOTE) + ) { + $conversation->setPreview($thread->body); + } + + $conversation->save(); + + // $is_new_conversation = false; + // if ($conversation->threads_count == 0 + // && in_array($thread->type, [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_NOTE]) + // && $thread->state == Thread::STATE_PUBLISHED + // ) { + // $is_new_conversation = true; + // } + + // User threads are created as drafts first. + // if ($thread->state == Thread::STATE_PUBLISHED) { + // \Eventy::action('thread.created', $thread); + // } + + // Real time for user notifications is sent using events. + if ($thread->type == Thread::TYPE_CUSTOMER + || ($thread->type == Thread::TYPE_MESSAGE && $thread->state == Thread::STATE_DRAFT) + ) { + Conversation::refreshConversations($conversation, $thread); + } + + \Eventy::action('thread.created', $thread); + } + + public function deleting(Thread $thread) + { + \Eventy::action('thread.deleting', $thread); + } + + public function updated(Thread $thread) + { + \Eventy::action('thread.updated', $thread); + } +} diff --git a/freescout-dist/app/Observers/UserObserver.php b/freescout-dist/app/Observers/UserObserver.php new file mode 100644 index 0000000..5b76374 --- /dev/null +++ b/freescout-dist/app/Observers/UserObserver.php @@ -0,0 +1,46 @@ +id); + } + + public function creating(User $user) + { + // This is a hack for backward compatibility. + if ($user->type == 0 && preg_match("#^fs.*@example\.org$#", $user->email) + ) { + $user->type = User::TYPE_ROBOT; + } + } + + /** + * On user delete. + * + * @param User $user + */ + public function deleting(User $user) + { + $user->folders()->delete(); + Follower::whereIn('user_id', $user->id)->delete(); + } +} diff --git a/freescout-dist/app/Option.php b/freescout-dist/app/Option.php new file mode 100644 index 0000000..f4d8a6f --- /dev/null +++ b/freescout-dist/app/Option.php @@ -0,0 +1,305 @@ + $name], ['value' => $serialized_value] + ); + + $old_value = $option['value']; + + if ($value === $old_value || self::maybeSerialize($value) === self::maybeSerialize($old_value)) { + return false; + } + + $option->value = $serialized_value; + $option->save(); + } + + /** + * Get option. + * + * @param string $name + * + * @return string + */ + public static function get($name, $default = false, $decode = true, $use_cache = true) + { + // If not passed, get default value from config + if (func_num_args() == 1) { + $default = self::getDefault($name, $default); + } + + if ($use_cache && isset(self::$cache[$name])) { + if (self::$cache[$name] == self::CACHE_DEFAULT_VALUE) { + return $default; + } else { + return self::$cache[$name]; + } + } + + $option = self::where('name', (string) $name)->first(); + if ($option) { + if ($decode) { + $value = self::maybeUnserialize($option->value); + } else { + $value = $option->value; + } + self::$cache[$name] = $value; + } else { + $value = $default; + self::$cache[$name] = self::CACHE_DEFAULT_VALUE; + } + + return $value; + } + + public static function getDefault($option_name, $default = false) + { + $options = \Config::get('app.options'); + + if (isset($options[$option_name]) && isset($options[$option_name]['default'])) { + return $options[$option_name]['default']; + } else { + // Try to get default option value from module's config. + preg_match("/^([a-z_]+)\.(.*)/", $option_name, $m); + + if (!empty($m[1]) && !empty($m[2])) { + $module_alias = $m[1]; + $modle_option_name = $m[2]; + $module_options = \Config::get($module_alias.'.options'); + if (isset($module_options[$modle_option_name]) && isset($module_options[$modle_option_name]['default'])) { + return $module_options[$modle_option_name]['default']; + } + } + + return $default; + } + } + + public static function isDefaultSet($option_name) + { + $options = \Config::get('app.options'); + + return isset($options[$option_name]) && isset($options[$option_name]['default']); + } + + /** + * Get multiple options. + * + * @param [type] $name [description] + * @param bool $default [description] + * @param bool $decode [description] + * + * @return [type] [description] + */ + public static function getOptions($options, $defaults = [], $decode = []) + { + $values = []; + + // Check in cache first + // Return if we can get all options from cache + foreach ($options as $name) { + if (isset(self::$cache[$name])) { + if (self::$cache[$name] == self::CACHE_DEFAULT_VALUE) { + if (!isset($defaults[$name])) { + $default = self::getDefault($name); + } else { + $default = $defaults[$name]; + } + $values[$name] = $default; + } else { + $values[$name] = self::$cache[$name]; + } + } + } + if (count($values) == count($options)) { + return $values; + } else { + $values = []; + } + + $db_options = self::whereIn('name', $options)->get(); + foreach ($options as $name) { + // If not passed, get default value from config + if (!isset($defaults[$name])) { + $default = self::getDefault($name); + } else { + $default = $defaults[$name]; + } + $db_option = $db_options->where('name', $name)->first(); + if ($db_option) { + // todo: decode + if (1 || $decode) { + $value = self::maybeUnserialize($db_option->value); + } else { + $value = $db_option->value; + } + self::$cache[$name] = $value; + } else { + $value = $default; + self::$cache[$name] = self::CACHE_DEFAULT_VALUE; + } + + $values[$name] = $value; + } + + return $values; + } + + public static function remove($name) + { + self::where('name', (string) $name)->delete(); + } + + /** + * Serialize data, if needed. + */ + public static function maybeSerialize($data) + { + if (is_array($data) || is_object($data)) { + return serialize($data); + } + + return $data; + } + + /** + * Unserialize data. + */ + public static function maybeUnserialize($original) + { + if (self::isSerialized($original)) { + try { + $original = unserialize($original); + } catch (\Exception $e) { + // Do nothing + } + + return $original; + } + + return $original; + } + + /** + * Check value to find if it was serialized. + * Serialized data is always a string. + */ + public static function isSerialized($data, $strict = true) + { + // if it isn't a string, it isn't serialized. + if (!is_string($data)) { + return false; + } + $data = trim($data); + if ('N;' == $data) { + return true; + } + if (strlen($data) < 4) { + return false; + } + if (':' !== $data[1]) { + return false; + } + if ($strict) { + $lastc = substr($data, -1); + if (';' !== $lastc && '}' !== $lastc) { + return false; + } + } else { + $semicolon = strpos($data, ';'); + $brace = strpos($data, '}'); + // Either ; or } must exist. + if (false === $semicolon && false === $brace) { + return false; + } + // But neither must be in the first X characters. + if (false !== $semicolon && $semicolon < 3) { + return false; + } + if (false !== $brace && $brace < 4) { + return false; + } + } + $token = $data[0]; + switch ($token) { + case 's': + if ($strict) { + if ('"' !== substr($data, -2, 1)) { + return false; + } + } elseif (false === strpos($data, '"')) { + return false; + } + // or else fall through + case 'a': + case 'O': + return (bool) preg_match("/^{$token}:[0-9]+:/s", $data); + case 'b': + case 'i': + case 'd': + $end = $strict ? '$' : ''; + + return (bool) preg_match("/^{$token}:[0-9.E-]+;$end/", $data); + } + + return false; + } + + /** + * Get company name. + */ + public static function getCompanyName() + { + return self::get('company_name', \Config::get('app.name')); + } +} diff --git a/freescout-dist/app/Policies/ConversationPolicy.php b/freescout-dist/app/Policies/ConversationPolicy.php new file mode 100644 index 0000000..b87fd3b --- /dev/null +++ b/freescout-dist/app/Policies/ConversationPolicy.php @@ -0,0 +1,118 @@ +isAdmin()) { + return true; + } else { + if ($conversation->mailbox->users->contains($user)) { + // Maybe user can see only assigned conversations. + if (!\Eventy::filter('conversation.is_user_assignee', $conversation->user_id == $user->id, $conversation, $user->id) + && $user->hasManageMailboxPermission($conversation->mailbox_id, Mailbox::ACCESS_PERM_ASSIGNED) + ) { + return false; + } else { + return true; + } + } else { + return false; + } + } + } + + /** + * Cached version. + * + * @param User $user [description] + * @param Conversation $conversation [description] + * @return [type] [description] + */ + public function viewCached(User $user, Conversation $conversation) + { + if ($user->isAdmin()) { + return true; + } else { + if ($conversation->mailbox->users_cached->contains($user)) { + // Maybe user can see only assigned conversations. + if (!\Eventy::filter('conversation.is_user_assignee', $conversation->user_id == $user->id, $conversation, $user->id) + && $user->hasManageMailboxPermission($conversation->mailbox_id, Mailbox::ACCESS_PERM_ASSIGNED) + ) { + return false; + } else { + return true; + } + } else { + return false; + } + } + } + + /** + * Determine whether the user can update the conversation. + * + * @param \App\User $user + * @param \App\Conversation $conversation + * + * @return bool + */ + public function update(User $user, Conversation $conversation) + { + if ($user->isAdmin()) { + return true; + } else { + if ($conversation->mailbox->users->contains($user)) { + return true; + } else { + return false; + } + } + } + + /** + * Check if user can delete conversation. + */ + public function delete(User $user, Conversation $conversation) + { + if ($user->isAdmin()) { + return true; + } else { + return $user->hasPermission(User::PERM_DELETE_CONVERSATIONS); + } + } + + /** + * Determine whether current user can move conversations + * + * @param \App\User $user + * @param \App\Mailbox $mailbox + * + * @return mixed + */ + public function move(User $user) + { + // First check this, because it is cached in conversation page + if (count($user->mailboxesCanView(true)) > 1) { + return true; + } + return Mailbox::count() > 1; + } +} diff --git a/freescout-dist/app/Policies/FolderPolicy.php b/freescout-dist/app/Policies/FolderPolicy.php new file mode 100644 index 0000000..3166eaf --- /dev/null +++ b/freescout-dist/app/Policies/FolderPolicy.php @@ -0,0 +1,33 @@ +isAdmin()) { + return true; + } else { + if ($folder->user_id == $user->id || $user->mailboxesSettings()->pluck('mailbox_id')->contains($folder->mailbox_id)) { + return true; + } else { + return false; + } + } + } +} diff --git a/freescout-dist/app/Policies/MailboxPolicy.php b/freescout-dist/app/Policies/MailboxPolicy.php new file mode 100644 index 0000000..190fc3f --- /dev/null +++ b/freescout-dist/app/Policies/MailboxPolicy.php @@ -0,0 +1,184 @@ +isAdmin()) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can view mailbox conversations. + * + * @param \App\User $user + * + * @return mixed + */ + public function view(User $user, Mailbox $mailbox) + { + if ($user->isAdmin()) { + return true; + } else { + if ($mailbox->users->contains($user)) { + return true; + } else { + return false; + } + } + } + + /** + * Determine whether the user can view mailbox conversations. + * + * @param \App\User $user + * + * @return mixed + */ + public function viewCached(User $user, Mailbox $mailbox) + { + if ($user->isAdmin()) { + return true; + } else { + // Use cached users for Realtime events + if ($mailbox->users_cached->contains($user)) { + return true; + } else { + return false; + } + } + } + + /** + * Determine whether the user can admin the mailbox. + * + * @param \App\User $user + * @param \App\Mailbox $mailbox + * + * @return mixed + */ + public function admin(User $user, Mailbox $mailbox) + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the mailbox. + * + * @param \App\User $user + * @param \App\Mailbox $mailbox + * + * @return mixed + */ + public function update(User $user, Mailbox $mailbox) + { + if ($user->isAdmin() || $user->canManageMailbox($mailbox->id)) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can update the mailbox auto reply. + * + * @param \App\User $user + * @param \App\Mailbox $mailbox + * + * @return mixed + */ + public function updateAutoReply(User $user, Mailbox $mailbox) + { + if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_AUTO_REPLIES)) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can update the mailbox Permissions. + * + * @param \App\User $user + * @param \App\Mailbox $mailbox + * + * @return mixed + */ + public function updatePermissions(User $user, Mailbox $mailbox) + { + if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_PERMISSIONS)) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can update the mailbox Permissions. + * + * @param \App\User $user + * @param \App\Mailbox $mailbox + * + * @return mixed + */ + public function updateSettings(User $user, Mailbox $mailbox) + { + if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_EDIT)) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can update the mailbox Email Signature. + * + * @param \App\User $user + * @param \App\Mailbox $mailbox + * + * @return mixed + */ + public function updateEmailSignature(User $user, Mailbox $mailbox) + { + if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_SIGNATURE)) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can delete the mailbox. + * + * @param \App\User $user + * @param \App\Mailbox $mailbox + * + * @return mixed + */ + public function delete(User $user, Mailbox $mailbox) + { + if ($user->isAdmin()) { + return true; + } else { + return false; + } + } +} diff --git a/freescout-dist/app/Policies/ThreadPolicy.php b/freescout-dist/app/Policies/ThreadPolicy.php new file mode 100644 index 0000000..c5d7953 --- /dev/null +++ b/freescout-dist/app/Policies/ThreadPolicy.php @@ -0,0 +1,42 @@ +created_by_user_id + && in_array($thread->type, [Thread::TYPE_MESSAGE, Thread::TYPE_NOTE]) + && ($user->isAdmin() || ($user->hasPermission(User::PERM_EDIT_CONVERSATIONS) && $thread->created_by_user_id == $user->id))) + || ($thread->created_by_customer_id && in_array($thread->type, [Thread::TYPE_CUSTOMER])) + ) { + return true; + } else { + return false; + } + } + + public function delete(User $user, Thread $thread) + { + if ($thread->created_by_user_id == $user->id) { + return true; + } else { + return false; + } + } +} diff --git a/freescout-dist/app/Policies/UserPolicy.php b/freescout-dist/app/Policies/UserPolicy.php new file mode 100644 index 0000000..69384ce --- /dev/null +++ b/freescout-dist/app/Policies/UserPolicy.php @@ -0,0 +1,121 @@ +isAdmin() || $user->id == $model->id) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can create models. + * + * @param \App\User $user + * + * @return mixed + */ + public function create(User $user) + { + if ($user->isAdmin() || $user->hasPermission(User::PERM_EDIT_USERS)) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can update the model. + * + * @param \App\User $user + * @param \App\User $model + * + * @return mixed + */ + public function update(User $user, User $model) + { + if ($user->isAdmin() + || $user->id == $model->id + || $user->hasPermission(User::PERM_EDIT_USERS) + || $user->canManageMailbox($model->id) + ) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can delete the model. + * + * @param \App\User $user + * @param \App\User $model + * + * @return mixed + */ + public function delete(User $user, User $model) + { + if ($user->isAdmin() /*|| $user->id == $model->id*/) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can change role of the user. + * + * @param \App\User $user + * + * @return mixed + */ + public function changeRole(User $user, User $model) + { + if ($user->isAdmin()) { + return true; + } else { + return false; + } + } + + /** + * Determine whether the user can view mailboxes menu. + * + * @param \App\User $user + * + * @return mixed + */ + public function viewMailboxMenu(User $user) + { + return true; + + // if ($user->isAdmin() || \Eventy::filter('user.can_view_mailbox_menu', false, $user)) { + // return true; + // // hasManageMailboxAccess creates an extra query on each page, + // // to avoid this we don't show Manage menu to users, + // // user can manage mailboxes from dashboard. + // } else if ($user->hasManageMailboxAccess()) { + // return true; + // } else { + // return false; + // } + } +} diff --git a/freescout-dist/app/Providers/AppServiceProvider.php b/freescout-dist/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..7b4bd65 --- /dev/null +++ b/freescout-dist/app/Providers/AppServiceProvider.php @@ -0,0 +1,96 @@ +app['url']->forceScheme('https'); + } + + // If APP_KEY is not set, redirect to /install.php + if (!\Config::get('app.key') && !app()->runningInConsole() && !file_exists(storage_path('.installed'))) { + // Not defined here yet + //\Artisan::call("freescout:clear-cache"); + redirect(\Helper::getSubdirectory().'/install.php')->send(); + } + + // Process module registration error - disable module and show error to admin + \Eventy::addFilter('modules.register_error', function ($exception, $module) { + + $msg = __('The :module_name module has been deactivated due to an error: :error_message', ['module_name' => $module->getName(), 'error_message' => $exception->getMessage()]); + + \Log::error($msg); + + // request() does is empty at this stage + if (!empty($_POST['action']) && $_POST['action'] == 'activate') { + + // During module activation in case of any error we have to deactivate module. + \App\Module::deactiveModule($module->getAlias()); + + \Session::flash('flashes_floating', [[ + 'text' => $msg, + 'type' => 'danger', + 'role' => \App\User::ROLE_ADMIN, + ]]); + + return; + } elseif (empty($_POST)) { + + // failed to open stream: No such file or directory + if (strstr($exception->getMessage(), 'No such file or directory')) { + \App\Module::deactiveModule($module->getAlias()); + + \Session::flash('flashes_floating', [[ + 'text' => $msg, + 'type' => 'danger', + 'role' => \App\User::ROLE_ADMIN, + ]]); + } + + return; + } + + return $exception; + }, 10, 2); + } +} diff --git a/freescout-dist/app/Providers/AuthServiceProvider.php b/freescout-dist/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000..8a0c6ba --- /dev/null +++ b/freescout-dist/app/Providers/AuthServiceProvider.php @@ -0,0 +1,33 @@ + 'App\Policies\UserPolicy', + 'App\Mailbox' => 'App\Policies\MailboxPolicy', + 'App\Folder' => 'App\Policies\FolderPolicy', + 'App\Conversation' => 'App\Policies\ConversationPolicy', + 'App\Thread' => 'App\Policies\ThreadPolicy', + ]; + + /** + * Register any authentication / authorization services. + * + * @return void + */ + public function boot() + { + $this->registerPolicies(); + + // + } +} diff --git a/freescout-dist/app/Providers/BroadcastServiceProvider.php b/freescout-dist/app/Providers/BroadcastServiceProvider.php new file mode 100644 index 0000000..e326d98 --- /dev/null +++ b/freescout-dist/app/Providers/BroadcastServiceProvider.php @@ -0,0 +1,27 @@ +app[\Illuminate\Broadcasting\BroadcastManager::class]->extend('polycast', function ($app, array $config) { + return new \App\Broadcasting\Broadcasters\PolycastBroadcaster(); + }); + + // This is not needed as we define routes in PolyastServiceProvider + //Broadcast::routes(); + + require base_path('routes/channels.php'); + } +} diff --git a/freescout-dist/app/Providers/EventServiceProvider.php b/freescout-dist/app/Providers/EventServiceProvider.php new file mode 100644 index 0000000..c519ba2 --- /dev/null +++ b/freescout-dist/app/Providers/EventServiceProvider.php @@ -0,0 +1,110 @@ + [ + 'App\Listeners\ProcessSwiftMessage', + ], + + 'Illuminate\Mail\Events\MessageSent' => [ + 'App\Listeners\RestartSwiftMailer', + ], + + 'Illuminate\Auth\Events\Registered' => [ + 'App\Listeners\LogRegisteredUser', + ], + + 'Illuminate\Auth\Events\Login' => [ + 'App\Listeners\RememberUserLocale', + 'App\Listeners\LogSuccessfulLogin', + 'App\Listeners\ActivateUser', + ], + + 'Illuminate\Auth\Events\Failed' => [ + 'App\Listeners\LogFailedLogin', + ], + + 'Illuminate\Auth\Events\Logout' => [ + 'App\Listeners\LogSuccessfulLogout', + ], + + 'Illuminate\Auth\Events\Lockout' => [ + 'App\Listeners\LogLockout', + ], + + 'Illuminate\Auth\Events\PasswordReset' => [ + 'App\Listeners\LogPasswordReset', + 'App\Listeners\SendPasswordChanged', + ], + + 'App\Events\UserDeleted' => [ + 'App\Listeners\LogUserDeletion', + ], + + 'App\Events\ConversationStatusChanged' => [ + 'App\Listeners\UpdateMailboxCounters', + ], + + 'App\Events\ConversationUserChanged' => [ + 'App\Listeners\UpdateMailboxCounters', + 'App\Listeners\SendNotificationToUsers', + ], + + 'App\Events\UserCreatedConversationDraft' => [ + + ], + + 'App\Events\UserCreatedThreadDraft' => [ + + ], + + 'App\Events\UserReplied' => [ + 'App\Listeners\SendReplyToCustomer', + 'App\Listeners\SendNotificationToUsers', + 'App\Listeners\RefreshConversations', + ], + + 'App\Events\CustomerReplied' => [ + 'App\Listeners\SendNotificationToUsers', + ], + + 'App\Events\UserCreatedConversation' => [ + 'App\Listeners\SendReplyToCustomer', + 'App\Listeners\SendNotificationToUsers', + 'App\Listeners\RefreshConversations', + ], + + 'App\Events\CustomerCreatedConversation' => [ + 'App\Listeners\SendAutoReply', + 'App\Listeners\SendNotificationToUsers', + ], + + 'App\Events\UserAddedNote' => [ + 'App\Listeners\SendNotificationToUsers', + 'App\Listeners\RefreshConversations', + ], + ]; + + /** + * Register any events for your application. + * + * @return void + */ + public function boot() + { + parent::boot(); + + // + } +} diff --git a/freescout-dist/app/Providers/PolycastServiceProvider.php b/freescout-dist/app/Providers/PolycastServiceProvider.php new file mode 100644 index 0000000..a270cbd --- /dev/null +++ b/freescout-dist/app/Providers/PolycastServiceProvider.php @@ -0,0 +1,197 @@ +extend('polycast', function(/*Application $app*/){ + // return new PolycastBroadcaster(); + // }); + //$factory->extend('polycast', function (Application $app, $config) { + //app('Illuminate\Broadcasting\BroadcastManager')->extend('polycast', function (array $config) { + //$broadcastManager->extend('polycast', function (array $config) { + + // This has to be done in BroadcastServiceProvider to avoid "Driver [driver] is not supported" error + // $this->app[BroadcastManager::class]->extend('polycast', function (array $config) { + // echo 'we are in extend'; + // return new \App\Misc\PolycastBroadcaster(); + // }); + + // $this->publishes([ + // __DIR__.'/../dist/js/polycast.js' => public_path('vendor/polycast/polycast.js'), + // __DIR__.'/../dist/js/polycast.min.js' => public_path('vendor/polycast/polycast.min.js'), + // ], 'public'); + + // $this->publishes([ + // __DIR__.'/../migrations/' => database_path('migrations') + // ], 'migrations'); + + $this->app['router']->group(['middleware' => ['web'], 'prefix' => \Helper::getSubdirectory()], function ($router) { + + // establish connection and send current time + $this->app['router']->post('polycast/connect', function (Request $request) { + return ['status' => 'success', 'time' => Carbon::now()->toDateTimeString()]; + }); + + // send payloads to requested browser + $this->app['router']->post('polycast/receive', function (Request $request) { + \Broadcast::auth($request); + + $query = \DB::table('polycast_events') + ->select('*'); + + $channels = $request->get('channels', []); + + foreach ($channels as $channel => $events) { + foreach ($events as $event) { + // No need to add index to DB for this query. + $query->orWhere(function ($query) use ($channel, $event, $request) { + $query->where('channels', 'like', '%"'.$channel.'"%') + ->where('event', '=', $event) + // Recors are fetched starting from opening the page or from the last event. + ->where('created_at', '>=', $request->get('time')); + }); + } + } + + $collection = collect($query->get()); + + $payload = $collection->map(function ($item, $key) use ($request) { + $created = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', $item->created_at); + $requested = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', $request->get('time')); + $item->channels = json_decode($item->channels, false); + $item->payload = json_decode($item->payload, false); + // Add extra data to the payload + // This works only if payload has medius and thread_id + $item->data = BroadcastNotification::fetchPayloadData($item->payload); + + $event_class = '\\'.$item->event; + if (method_exists($event_class, "processPayload")) { + // If user is not allowed to access this event, data will be sent to empty array. + $item->payload = $event_class::processPayload($item->payload); + } + + $item->delay = $requested->diffInSeconds($created); + $item->requested_at = $requested->toDateTimeString(); + + return $item; + }); + + // Reflash session data - otherwise on reply flash alert is not displayed + // https://stackoverflow.com/questions/37019294/laravel-ajax-call-deletes-session-flash-data + \Session::reflash(); + + $this->processConvView($request); + + \Eventy::action('polycast.receive', $request, $collection, $payload); + + return ['status' => 'success', 'time' => Carbon::now()->toDateTimeString(), 'payloads' => $payload]; + }); + }); + } + + /** + * Process conversation viewers. + */ + public function processConvView($request) + { + // Periodically save info indicating that user is still viewing the conversation. + $viewing_conversation_id = null; + if (!empty($request->data) && !empty($request->data['conversation_id'])) { + $viewing_conversation_id = $request->data['conversation_id']; + + if (!empty($request->data['conversation_view_focus'])) { + $conversation = Conversation::find($request->data['conversation_id']); + if ($conversation) { + \Eventy::action('conversation.view.focus', $conversation); + } + } + } + if ($viewing_conversation_id) { + + $user = auth()->user(); + $now = Carbon::now(); + + $cache_key = 'conv_view_'.$user->id.'_'.$viewing_conversation_id; + $cache_data = \Cache::get($cache_key); + $view_date = null; + $replying_changed = false; + + // t - date. + // r - replying. + if ($cache_data) { + if (isset($cache_data['t']) && isset($cache_data['r'])) { + $view_date = Carbon::createFromFormat('Y-m-d H:i:s', $cache_data['t']); + + // Let other users know that user started to reply. + if (!(int)$cache_data['r'] && (int)$request->data['replying']) { + // Started to reply. + \App\Events\RealtimeConvView::dispatchSelf($viewing_conversation_id, $user, true); + $replying_changed = true; + } elseif ((int)$cache_data['r'] && !(int)$request->data['replying']) { + // Finished to reply. + \App\Events\RealtimeConvView::dispatchSelf($viewing_conversation_id, $user, false); + $replying_changed = true; + } + } else { + $replying_changed = true; + } + } + + if (!$cache_data || $replying_changed || ($view_date && $now->diffInSeconds($view_date) > 15)) { + // Remember date of the last view in the cache. + // Store for 2 minutes. + $cache_data = [ + 't' => $now->toDateTimeString(), + 'r' => (int)$request->data['replying'] + ]; + \Cache::put($cache_key, $cache_data, 1); + + // Job could not detect when user finishes to view converrsation. + // We are using cron. + // \App\Jobs\CheckConvView::dispatch($viewing_conversation_id, $user->id) + // ->delay(now()->addSeconds(25)) + // ->onQueue(\Helper::QUEUE_DEFAULT); + + $conv_key = 'conv_view'; + $conv_data = \Cache::get($conv_key) ?? []; + $conv_data[$viewing_conversation_id][$user->id] = $cache_data; + \Cache::put($conv_key, $conv_data, 20 /*minutes*/); + + // \DB::table('polycast_events')->insert([ + // 'channels' => json_encode([['name' => 'conv.view']]), + // 'event' => 'App\Events\RealtimeConvView', + // 'payload' => json_encode([ + // 'conversation_id' => $viewing_conversation_id, + // 'reiterating' => true + // ]), + // 'created_at' => Carbon::now()->toDateTimeString(), + // ]); + } + } + } + + public function register() + { + } +} diff --git a/freescout-dist/app/Providers/RouteServiceProvider.php b/freescout-dist/app/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..d4c0f60 --- /dev/null +++ b/freescout-dist/app/Providers/RouteServiceProvider.php @@ -0,0 +1,81 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + + // + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + $subdirectory = \Helper::getSubdirectory(); + + if ($subdirectory) { + $route = Route::prefix($subdirectory) + ->middleware('web'); + } else { + $route = Route::middleware('web'); + } + + $route->namespace($this->namespace) + ->group(base_path('routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + // protected function mapApiRoutes() + // { + // Route::prefix('api') + // ->middleware('api') + // ->namespace($this->namespace) + // ->group(base_path('routes/api.php')); + // } +} diff --git a/freescout-dist/app/SendLog.php b/freescout-dist/app/SendLog.php new file mode 100644 index 0000000..7342f85 --- /dev/null +++ b/freescout-dist/app/SendLog.php @@ -0,0 +1,187 @@ +belongsTo('App\Customer'); + } + + /** + * User. + */ + public function user() + { + return $this->belongsTo('App\User'); + } + + /** + * Thread. + */ + public function thread() + { + return $this->belongsTo('App\Thread'); + } + + /** + * Save log record. + */ + public static function log($thread_id, $message_id, $email, $mail_type, $status, $customer_id = null, $user_id = null, $status_message = null, $smtp_queue_id = null) + { + $send_log = new self(); + $send_log->thread_id = $thread_id; + $send_log->message_id = $message_id; + $send_log->email = $email; + $send_log->mail_type = $mail_type; + $send_log->status = $status; + $send_log->customer_id = $customer_id; + $send_log->user_id = $user_id; + $send_log->status_message = $status_message; + if ($smtp_queue_id) { + $send_log->smtp_queue_id = $smtp_queue_id; + } + try { + $send_log->save(); + } catch (\Exception $e) { + \Helper::logException($e, 'Error occurred saving a record to `send_logs` table. '); + return false; + } + + return true; + } + + /** + * Get name of the status. + */ + public function getStatusName() + { + switch ($this->status) { + case self::STATUS_ACCEPTED: + return __('Accepted for delivery'); + case self::STATUS_SEND_ERROR: + return __('Send error'); + case self::STATUS_DELIVERY_SUCCESS: + return __('Successfully delivered'); + case self::STATUS_DELIVERY_ERROR: + return __('Delivery error'); + case self::STATUS_OPENED: + return __('Recipient opened the message'); + case self::STATUS_CLICKED: + return __('Recipient clicked a link in the message'); + case self::STATUS_UNSUBSCRIBED: + return __('Recipient unsubscribed'); + case self::STATUS_COMPLAINED: + return __('Recipient complained'); + default: + return __('Unknown'); + } + } + + public function isErrorStatus() + { + if (in_array($this->status, self::$status_errors)) { + return true; + } else { + return false; + } + } + + public function isSuccessStatus() + { + if (in_array($this->status, [self::STATUS_DELIVERY_SUCCESS])) { + return true; + } else { + return false; + } + } + + public function getMailTypeName() + { + switch ($this->mail_type) { + case self::MAIL_TYPE_EMAIL_TO_CUSTOMER: + return __('Email to customer'); + case self::MAIL_TYPE_USER_NOTIFICATION: + return __('User notification'); + case self::MAIL_TYPE_AUTO_REPLY: + return __('Auto reply to customer'); + case self::MAIL_TYPE_INVITE: + return __('User invite'); + case self::MAIL_TYPE_PASSWORD_CHANGED: + return __('Password changed notification'); + case self::MAIL_TYPE_WRONG_USER_EMAIL_MESSAGE: + return __('User replied from wrong email address'); + case self::MAIL_TYPE_TEST: + return __('Test email'); + case self::MAIL_TYPE_ALERT: + return __('Alert email'); + } + } +} diff --git a/freescout-dist/app/Sendmail.php b/freescout-dist/app/Sendmail.php new file mode 100644 index 0000000..5201c63 --- /dev/null +++ b/freescout-dist/app/Sendmail.php @@ -0,0 +1,34 @@ +belongsTo('App\Customer'); + } + + /** + * User. + */ + public function user() + { + return $this->belongsTo('App\User'); + } +} diff --git a/freescout-dist/app/Subscription.php b/freescout-dist/app/Subscription.php new file mode 100644 index 0000000..684ed1c --- /dev/null +++ b/freescout-dist/app/Subscription.php @@ -0,0 +1,471 @@ + [ + self::EVENT_CONVERSATION_ASSIGNED_TO_ME, + self::EVENT_FOLLOWED_CONVERSATION_UPDATED, + //self::EVENT_MY_TEAM_MENTIONED, + self::EVENT_CUSTOMER_REPLIED_TO_MY, + self::EVENT_USER_REPLIED_TO_MY, + ], + self::MEDIUM_BROWSER => [ + self::EVENT_CONVERSATION_ASSIGNED_TO_ME, + self::EVENT_FOLLOWED_CONVERSATION_UPDATED, + //self::EVENT_MY_TEAM_MENTIONED, + self::EVENT_CUSTOMER_REPLIED_TO_MY, + self::EVENT_USER_REPLIED_TO_MY, + ], + ]; + + /** + * List of events that occurred. + */ + public static $occurred_events = []; + + public $timestamps = false; + + /** + * The attributes that are not mass assignable. + * + * @var array + */ + protected $guarded = ['id']; + + /** + * Subscribed user. + */ + public function user() + { + return $this->belongsTo('App\User'); + } + + /** + * Add default subscriptions for user. + * + * @param int $user_id + */ + public static function addDefaultSubscriptions($user_id) + { + self::saveFromArray(self::getDefaultSubscriptions(), $user_id); + } + + public static function getDefaultSubscriptions() + { + return Option::get('subscription_defaults', self::$default_subscriptions); + } + + /** + * Save subscriptions from passed array. + * + * @param array $subscriptions [description] + * + * @return [type] [description] + */ + public static function saveFromArray($new_subscriptions, $user_id) + { + $subscriptions = []; + + if (is_array($new_subscriptions)) { + foreach ($new_subscriptions as $medium => $events) { + foreach ($events as $event) { + $subscriptions[] = [ + 'user_id' => $user_id, + 'medium' => $medium, + 'event' => $event, + ]; + } + } + } + + self::where('user_id', $user_id)->delete(); + self::insert($subscriptions); + } + + /** + * Check if subscription exists. + */ + public static function exists(array $params, $subscriptions = null) + { + if ($subscriptions) { + // Look in the passed list + foreach ($subscriptions as $subscription) { + foreach ($params as $param_name => $param_value) { + if ($subscription->$param_name != $param_value) { + continue 2; + } + } + + return true; + } + } else { + // Search in DB + } + + return false; + } + + /** + * Detect users to notify by medium. + */ + public static function usersToNotify($event_type, $conversation, $threads, $mailbox_user_ids = null) + { + $users_to_notify = []; + $thread = null; + + if (isset($threads[0])) { + $thread = $threads[0]; + } elseif (count($threads)) { + $thread = array_shift(array_values($threads)); + } + + if (!$thread) { + return $users_to_notify; + } + + // Ignore imported threads. + if ($thread->imported) { + return true; + } + + // Detect events + $events = []; + + $prev_thread = null; + if (!empty($threads[1])) { + $prev_thread = $threads[1]; + } + + switch ($event_type) { + case self::EVENT_TYPE_NEW: + $events[] = self::EVENT_NEW_CONVERSATION; + break; + + case self::EVENT_TYPE_ASSIGNED: + $events[] = self::EVENT_CONVERSATION_ASSIGNED_TO_ME; + $events[] = self::EVENT_CONVERSATION_ASSIGNED; + break; + + case self::EVENT_TYPE_CUSTOMER_REPLIED: + $events[] = self::EVENT_FOLLOWED_CONVERSATION_UPDATED; + if (!empty($prev_thread) && $prev_thread->user_id) { + $events[] = self::EVENT_CUSTOMER_REPLIED_TO_MY; + $events[] = self::EVENT_CUSTOMER_REPLIED_TO_ASSIGNED; + } else { + $events[] = self::EVENT_CUSTOMER_REPLIED_TO_UNASSIGNED; + } + break; + + case self::EVENT_TYPE_USER_REPLIED: + case self::EVENT_TYPE_USER_ADDED_NOTE: + $events[] = self::EVENT_FOLLOWED_CONVERSATION_UPDATED; + if (!empty($prev_thread) && $prev_thread->user_id) { + $events[] = self::EVENT_USER_REPLIED_TO_MY; + $events[] = self::EVENT_USER_REPLIED_TO_ASSIGNED; + } else { + $events[] = self::EVENT_USER_REPLIED_TO_UNASSIGNED; + } + break; + } + + $events = \Eventy::filter('subscription.events_by_type', $events, $event_type, $thread); + + // Check if assigned user changed + $user_changed = false; + if ($event_type != self::EVENT_TYPE_ASSIGNED && $event_type != self::EVENT_TYPE_NEW) { + if ($thread->type == Thread::TYPE_LINEITEM && $thread->action_type == Thread::ACTION_TYPE_USER_CHANGED) { + $user_changed = true; + } elseif ($prev_thread) { + if ($prev_thread->user_id != $thread->user_id) { + $user_changed = true; + } + } else { + // Get prev thread + if ($prev_thread && $prev_thread->user_id != $thread->user_id) { + $user_changed = true; + } + } + } + if ($user_changed) { + $events[] = self::EVENT_CONVERSATION_ASSIGNED_TO_ME; + $events[] = self::EVENT_CONVERSATION_ASSIGNED; + $events[] = self::EVENT_FOLLOWED_CONVERSATION_UPDATED; + } + $events = array_unique($events); + + // Detect subscribed users + if (!$mailbox_user_ids) { + $mailbox_user_ids = $conversation->mailbox->userIdsHavingAccess(); + } + + $subscriptions = self::whereIn('user_id', $mailbox_user_ids) + ->whereIn('event', $events) + ->get(); + + $subscriptions = \Eventy::filter('subscription.subscriptions', $subscriptions, $conversation, $events, $thread); + + // Filter subscribers + foreach ($subscriptions as $i => $subscription) { + // Actions on conversation where user is assignee + if (in_array($subscription->event, [self::EVENT_CONVERSATION_ASSIGNED_TO_ME, self::EVENT_CUSTOMER_REPLIED_TO_MY, self::EVENT_USER_REPLIED_TO_MY]) + && ($conversation->user_id != $subscription->user_id && !\Eventy::filter('subscription.is_user_assignee', false, $subscription, $conversation)) + ) { + continue; + } + + // Check if user is following this conversation. + if ($subscription->event == self::EVENT_FOLLOWED_CONVERSATION_UPDATED + && !$conversation->isUserFollowing($subscription->user_id) + ) { + continue; + } + + // Skip if user muted notifications for this mailbox + //if ($subscription->user->isAdmin()) { + + // Mute notifications for events not related directly to the user. + if (!in_array($subscription->event, [self::EVENT_CONVERSATION_ASSIGNED_TO_ME, self::EVENT_FOLLOWED_CONVERSATION_UPDATED, self::EVENT_CUSTOMER_REPLIED_TO_MY, self::EVENT_USER_REPLIED_TO_MY]) + && !\Eventy::filter('subscription.is_related_to_user', false, $subscription, $thread) + ) { + $mailbox_settings = $conversation->mailbox->getUserSettings($subscription->user_id); + + if (!empty($mailbox_settings->mute)) { + continue; + } + } + //} + + if (\Eventy::filter('subscription.filter_out', false, $subscription, $thread)) { + continue; + } + + $users_to_notify[$subscription->medium][] = $subscription->user; + $users_to_notify[$subscription->medium] = array_unique($users_to_notify[$subscription->medium]); + } + + // Add menu notifications, for example. + $users_to_notify = \Eventy::filter('subscription.users_to_notify', $users_to_notify, $event_type, $events, $thread); + + return $users_to_notify; + } + + /** + * Process events which occurred. + */ + public static function processEvents() + { + $notify = []; + + $delay = now()->addSeconds(Conversation::UNDO_TIMOUT); + + // Collect into notify array information about all users who need to be notified + foreach (self::$occurred_events as $event) { + // Get mailbox users ids + $mailbox_user_ids = []; + foreach (self::$mediums as $medium) { + if (!empty($notify[$medium])) { + foreach ($notify[$medium] as $conversation_id => $notify_info) { + if ($notify_info['conversation']->mailbox_id == $event['conversation']->mailbox_id) { + $mailbox_user_ids = $notify_info['mailbox_user_ids']; + break 2; + } + } + } + } + + // Get users and threads from previous results to avoid repeated SQL queries. + $users = []; + $threads = []; + foreach (self::$mediums as $medium) { + if (empty($notify[$medium][$event['conversation']->id])) { + $threads = $event['conversation']->getThreads(); + break; + } else { + $users[$medium] = $notify[$medium][$event['conversation']->id]['users']; + $threads = $notify[$medium][$event['conversation']->id]['threads']; + } + } + + $users_to_notify = self::usersToNotify($event['event_type'], $event['conversation'], $threads, $mailbox_user_ids); + + if (!$users_to_notify || !is_array($users_to_notify)) { + continue; + } + + foreach ($users_to_notify as $medium => $medium_users_to_notify) { + + // Remove current user from recipients if action caused by current user + foreach ($medium_users_to_notify as $i => $user) { + if ($user->id == $event['caused_by_user_id']) { + unset($medium_users_to_notify[$i]); + } + } + + if (count($medium_users_to_notify)) { + $notify[$medium][$event['conversation']->id] = [ + // Users subarray contains all users who need to receive notification + // for all events for the media. + 'users' => array_unique(array_merge($users[$medium] ?? [], $medium_users_to_notify)), + 'conversation' => $event['conversation'], + 'threads' => $threads, + 'mailbox_user_ids' => $mailbox_user_ids, + ]; + } + } + } + + // - Email notification (better to create them first) + if (!empty($notify[self::MEDIUM_EMAIL])) { + foreach ($notify[self::MEDIUM_EMAIL] as $conversation_id => $notify_info) { + \App\Jobs\SendNotificationToUsers::dispatch($notify_info['users'], $notify_info['conversation'], $notify_info['threads']) + ->delay($delay) + ->onQueue('emails'); + } + } + + // - Menu notification (uses same medium as for Email, if email notifications are disabled - use Browser notificaitons) + if (!empty($notify[self::MEDIUM_EMAIL]) + || !empty($notify[self::MEDIUM_BROWSER]) + || !empty($notify[self::MEDIUM_MENU]) + ) { + if (!empty($notify[self::MEDIUM_EMAIL])) { + $notify_menu = $notify[self::MEDIUM_EMAIL] ?? []; + } else { + $notify_menu = $notify[self::MEDIUM_BROWSER] ?? []; + } + $notify_menu = $notify_menu + ($notify[self::MEDIUM_MENU] ?? []); + foreach ($notify_menu as $notify_info) { + $website_notification = new WebsiteNotification($notify_info['conversation'], self::chooseThread($notify_info['threads'])); + $website_notification->delay($delay); + \Notification::send($notify_info['users'], $website_notification); + } + } + + // Send broadcast notifications: + // - Browser push notification + $broadcasts = []; + foreach ([self::MEDIUM_EMAIL, self::MEDIUM_BROWSER] as $medium) { + if (empty($notify[$medium])) { + continue; + } + foreach ($notify[$medium] as $notify_info) { + $thread_id = self::chooseThread($notify_info['threads'])->id; + + foreach ($notify_info['users'] as $user) { + $mediums = [$medium]; + if (!empty($broadcasts[$thread_id]['mediums'])) { + $mediums = array_unique(array_merge($mediums, $broadcasts[$thread_id]['mediums'])); + } + $broadcasts[$thread_id] = [ + 'user' => $user, + 'conversation' => $notify_info['conversation'], + 'threads' => $notify_info['threads'], + 'mediums' => $mediums, + ]; + } + } + } + // \Notification::sendNow($notify_info['users'], new BroadcastNotification($notify_info['conversation'], $notify_info['threads'][0])); + foreach ($broadcasts as $thread_id => $to_broadcast) { + $broadcast_notification = new BroadcastNotification($to_broadcast['conversation'], self::chooseThread($to_broadcast['threads']), $to_broadcast['mediums']); + $broadcast_notification->delay($delay); + $to_broadcast['user']->notify($broadcast_notification); + } + + // - Mobile + \Eventy::action('subscription.process_events', $notify); + + self::$occurred_events = []; + } + + /** + * Get fist meaningful thread for the notification. + */ + public static function chooseThread($threads) + { + $actions_types = [ + Thread::ACTION_TYPE_USER_CHANGED, + ]; + // First thread is the newest. + foreach ($threads as $thread) { + if ($thread->type == Thread::TYPE_LINEITEM && !in_array($thread->action_type, $actions_types)) { + continue; + } else { + return $thread; + } + } + return $threads[0]; + } + + /** + * Remember event type to process in ProcessSubscriptionEvents middleware on terminate. + */ + public static function registerEvent($event_type, $conversation, $caused_by_user_id, $process_now = false) + { + self::$occurred_events[] = [ + 'event_type' => $event_type, + 'conversation' => $conversation, + 'caused_by_user_id' => $caused_by_user_id, + ]; + + // Automatically add EVENT_TYPE_UPDATED + if (!in_array($event_type, [self::EVENT_TYPE_UPDATED, self::EVENT_TYPE_NEW])) { + self::$occurred_events[] = [ + 'event_type' => self::EVENT_TYPE_UPDATED, + 'conversation' => $conversation, + 'caused_by_user_id' => $caused_by_user_id, + ]; + } + if ($process_now) { + self::processEvents(); + } + } +} diff --git a/freescout-dist/app/Thread.php b/freescout-dist/app/Thread.php new file mode 100644 index 0000000..2d75913 --- /dev/null +++ b/freescout-dist/app/Thread.php @@ -0,0 +1,1541 @@ + 'customer', + self::PERSON_USER => 'user', + ]; + + /** + * Thread types. + */ + // Email from customer + const TYPE_CUSTOMER = 1; + // Thead created by user + const TYPE_MESSAGE = 2; + const TYPE_NOTE = 3; + // Thread status change + const TYPE_LINEITEM = 4; + //const TYPE_PHONE = 5; + // Forwarded threads - used in API only. + //const TYPE_FORWARDPARENT = 6; + //const TYPE_FORWARDCHILD = 7; + const TYPE_CHAT = 8; + + public static $types = [ + // Thread by customer + self::TYPE_CUSTOMER => 'customer', + // Thread by user + self::TYPE_MESSAGE => 'message', + self::TYPE_NOTE => 'note', + // lineitem represents a change of state on the conversation. This could include, but not limited to, the conversation was assigned, the status changed, the conversation was moved from one mailbox to another, etc. A line item won’t have a body, to/cc/bcc lists, or attachments. + self::TYPE_LINEITEM => 'lineitem', + //self::TYPE_PHONE => 'phone', + // When a conversation is forwarded, a new conversation is created to represent the forwarded conversation. + // forwardparent is the type set on the thread of the original conversation that initiated the forward event. + //self::TYPE_FORWARDPARENT => 'forwardparent', + // forwardchild is the type set on the first thread of the new forwarded conversation. + //self::TYPE_FORWARDCHILD => 'forwardchild', + // Not used. + self::TYPE_CHAT => 'chat', + ]; + + /** + * Subtypes (for notes mostly) + */ + const SUBTYPE_FORWARD = 1; + const SUBTYPE_PHONE = 2; + + /** + * Statuses (code must be equal to conversations statuses). + */ + const STATUS_ACTIVE = 1; + const STATUS_PENDING = 2; + const STATUS_CLOSED = 3; + const STATUS_SPAM = 4; + const STATUS_NOCHANGE = 6; + + public static $statuses = [ + self::STATUS_ACTIVE => 'active', + self::STATUS_CLOSED => 'closed', + self::STATUS_NOCHANGE => 'nochange', + self::STATUS_PENDING => 'pending', + self::STATUS_SPAM => 'spam', + ]; + + /** + * States. + */ + const STATE_DRAFT = 1; + const STATE_PUBLISHED = 2; + const STATE_HIDDEN = 3; + // A state of review means the thread has been stopped by Traffic Cop and is waiting + // to be confirmed (or discarded) by the person that created the thread. + const STATE_REVIEW = 4; + + public static $states = [ + self::STATE_DRAFT => 'draft', + self::STATE_PUBLISHED => 'published', + self::STATE_HIDDEN => 'hidden', + self::STATE_REVIEW => 'review', + ]; + + /** + * Action associated with the line item. + * It is recommended to add custom action types between 100 and 1000 + */ + // Conversation's status changed + const ACTION_TYPE_STATUS_CHANGED = 1; + // Conversation's assignee changed + const ACTION_TYPE_USER_CHANGED = 2; + // The conversation was moved from another mailbox + const ACTION_TYPE_MOVED_FROM_MAILBOX = 3; + // Another conversation was merged with this conversation + const ACTION_TYPE_MERGED = 4; + // The conversation was imported (no email notifications were sent) + const ACTION_TYPE_IMPORTED = 5; + // A workflow was run on this conversation (either automatic or manual) + // const ACTION_TYPE_WORKFLOW_MANUAL = 6; + // const ACTION_TYPE_WORKFLOW_AUTO = 7; + // The ticket was imported from an external Service + const ACTION_TYPE_IMPORTED_EXTERNAL = 8; + // Conversation customer changed + const ACTION_TYPE_CUSTOMER_CHANGED = 9; + // The ticket was deleted + const ACTION_TYPE_DELETED_TICKET = 10; + // The ticket was restored + const ACTION_TYPE_RESTORE_TICKET = 11; + + // Describes an optional action associated with the line item + public static $action_types = [ + self::ACTION_TYPE_STATUS_CHANGED => 'changed-ticket-status', + self::ACTION_TYPE_USER_CHANGED => 'changed-ticket-assignee', + self::ACTION_TYPE_MOVED_FROM_MAILBOX => 'moved-from-mailbox', + self::ACTION_TYPE_MERGED => 'merged', + self::ACTION_TYPE_IMPORTED => 'imported', + // self::ACTION_TYPE_WORKFLOW_MANUAL => 'manual-workflow', + // self::ACTION_TYPE_WORKFLOW_AUTO => 'automatic-workflow', + self::ACTION_TYPE_IMPORTED_EXTERNAL => 'imported-external', + self::ACTION_TYPE_CUSTOMER_CHANGED => 'changed-ticket-customer', + self::ACTION_TYPE_DELETED_TICKET => 'deleted-ticket', + self::ACTION_TYPE_RESTORE_TICKET => 'restore-ticket', + ]; + + /** + * Source types (equal to thread source types). + */ + const SOURCE_TYPE_EMAIL = 1; + const SOURCE_TYPE_WEB = 2; + const SOURCE_TYPE_API = 3; + + public static $source_types = [ + self::SOURCE_TYPE_EMAIL => 'email', + self::SOURCE_TYPE_WEB => 'web', + self::SOURCE_TYPE_API => 'api', + ]; + + // Metas. + const META_CONVERSATION_HISTORY = 'ch'; + const META_PREV_CONVERSATION = 'pc'; + const META_MERGED_WITH_CONV = 'mwc'; + const META_MERGED_INTO_CONV = 'mic'; + const META_FORWARD_PARENT_CONVERSATION_NUMBER = 'fw_pcn'; + const META_FORWARD_PARENT_CONVERSATION_ID = 'fw_pci'; + const META_FORWARD_PARENT_THREAD_ID = 'fw_pti'; + const META_FORWARD_CHILD_CONVERSATION_NUMBER = 'fw_ccn'; + const META_FORWARD_CHILD_CONVERSATION_ID = 'fw_cci'; + + // At some stage metas have been renamed. + public static $meta_fw_backward_compat = [ + self::META_FORWARD_PARENT_CONVERSATION_NUMBER => 'forward_parent_conversation_number', + self::META_FORWARD_PARENT_CONVERSATION_ID => 'forward_parent_conversation_id', + self::META_FORWARD_PARENT_THREAD_ID => 'forward_parent_thread_id', + self::META_FORWARD_CHILD_CONVERSATION_NUMBER => 'forward_child_conversation_number', + self::META_FORWARD_CHILD_CONVERSATION_ID => 'forward_child_conversation_id', + ]; + + protected $dates = [ + 'opened_at', + 'created_at', + 'updated_at', + 'deleted_at', + 'edited_at', + ]; + + protected $casts = [ + 'meta' => 'array', + ]; + + /** + * The user assigned to this thread (assignedTo). + */ + public function user() + { + return $this->belongsTo('App\User'); + } + + /** + * The user assigned to this thread (cached). + */ + public function user_cached() + { + return $this->user()->rememberForever(); + } + + /** + * Get the thread customer. + */ + public function customer() + { + return $this->belongsTo('App\Customer'); + } + + /** + * Get the thread customer (cached). + */ + public function customer_cached() + { + return $this->customer()->rememberForever(); + } + + /** + * Get conversation. + */ + public function conversation() + { + return $this->belongsTo('App\Conversation'); + } + + /** + * Get thread attachmets. + */ + public function attachments() + { + return $this->hasMany('App\Attachment')->where('embedded', false); + //return $this->hasMany('App\Attachment'); + } + + /** + * Get thread embedded attachments. + */ + public function embeds() + { + return $this->hasMany('App\Attachment')->where('embedded', true); + } + + /** + * All kinds of attachments including embedded. + */ + public function all_attachments() + { + return $this->hasMany('App\Attachment'); + } + + /** + * Get user who created the thread. + */ + public function created_by_user() + { + return $this->belongsTo('App\User'); + } + + /** + * Get user who created the thread (cached). + */ + public function created_by_user_cached() + { + return $this->created_by_user()->rememberForever(); + } + + /** + * Get customer who created the thread. + */ + public function created_by_customer() + { + return $this->belongsTo('App\Customer'); + } + + /** + * Get user who edited thread. + */ + public function edited_by_user() + { + return $this->belongsTo('App\User'); + } + + /** + * Get user who edited thread (cached). + */ + public function edited_by_user_cached() + { + return $this->edited_by_user()->rememberForever(); + } + + /** + * Get sanitized body HTML. + * + * @return string + */ + public function getCleanBody($body = '') + { + if (!$body) { + $body = $this->body; + } + + if ($body === null) { + $body = ''; + } + + // Change "background:" to "background-color:". + // https://github.com/freescout-helpdesk/freescout/issues/2560 + // Keep in mind that with large texts preg_replace() may return null. + $body = preg_replace("/(<[^<>]+style=[\"'][^\"']*)background: *([^;() ]+[;\"'])/", '$1background-color:$2', $body) ?: $body; + + // Cut out "collapse" class as it hides elements. + $body = preg_replace("/(<[^<>\r\n]+class=([\"'][^\"']* |[\"']))(collapse|hidden)([\"' ])/", '$1$4', $body) ?: $body; + + return \Helper::purifyHtml($body); + } + + /** + * Convert body to plain text. + */ + public function getBodyAsText($options = ['width' => 0]) + { + return \Helper::htmlToText($this->body, true, $options); + } + + public function getBodyWithFormatedLinks(string $body = '') :string + { + if (!$body) { + $body = $this->body; + } + + $body = \Helper::linkify($this->getCleanBody($body)); + + // Add target="_blank" to links. + $pattern = '//i'; + + $body = preg_replace_callback($pattern, function($m){ + $tpl = array_shift($m); + $href = isset($m[1]) ? $m[1] : null; + + if (preg_match('/target=[\'"]?(.*?)[\'"]?/i', $tpl)) { + return $tpl; + } + + if (trim($href) && 0 === strpos($href, '#')) { + // Anchor links. + return $tpl; + } + + return preg_replace_callback('/href=/i', function($m2){ + return sprintf('target="_blank" %s', array_shift($m2)); + }, $tpl); + + }, $body) ?: $body; + + return $body; + } + + /** + * Get sanitized body HTML. + * + * @return string + */ + public function getCleanBodyOriginal() + { + return $this->getCleanBody($this->body_original); + } + + /** + * Get thread recipients. + * + * @return array + */ + public function getToArray($exclude_array = []) + { + return \App\Misc\Helper::jsonToArray($this->to, $exclude_array); + } + + public function getToString($exclude_array = []) + { + return implode(', ', $this->getToArray($exclude_array)); + } + + /** + * Get first address from the To list. + */ + public function getToFirst() + { + $to = $this->getToArray(); + + return array_shift($to); + } + + /** + * Get type name. + */ + public function getTypeName() + { + return self::$types[$this->type]; + } + + /** + * Get thread CC recipients. + * + * @return array + */ + public function getCcArray($exclude_array = []) + { + return \App\Misc\Helper::jso