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 + + RewriteEngine on + # On some hostings it does not work as "public/$1", but works as "/public/$1". + # But we can't write it as "/public/$1" because in this case it does not work + # when application is installed in subdirectory. + RewriteCond %{REQUEST_URI} !/\.well\-known/?.* + RewriteRule (.*) public/$1 [L] + \ No newline at end of file diff --git a/freescout-dist/.travis.yml b/freescout-dist/.travis.yml new file mode 100644 index 0000000..e83a632 --- /dev/null +++ b/freescout-dist/.travis.yml @@ -0,0 +1,15 @@ +language: php + +php: + - 7.0 + - 7.2 + - 7.4 + - 8.0 + +before_script: + - cp .env.travis .env + - mysql -e 'create database homestead_test;' + +# phpunit testing is disabled as dist vendor is cleared by removing unneeded files +#script: +# - vendor/bin/phpunit \ No newline at end of file diff --git a/freescout-dist/LICENSE b/freescout-dist/LICENSE new file mode 100644 index 0000000..da7618d --- /dev/null +++ b/freescout-dist/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defences to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/freescout-dist/README.md b/freescout-dist/README.md new file mode 100644 index 0000000..df97bae --- /dev/null +++ b/freescout-dist/README.md @@ -0,0 +1,150 @@ +# Free Self-Hosted Zendesk & Help Scout Alternative + +
+ + +

+ +[![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) + +
+ +**FreeScout** is the super lightweight and powerful free open source help desk and shared inbox built with PHP (Laravel framework). Now you can enjoy free Zendesk & Help Scout without giving up privacy or locking yourself into a service you don't control. FreeScout has been developed from scratch and is not using any copyrighted Help Scout or Zendesk materials. + +If you want to support the project feel free to **star this repository**. It helps to increase the visibility of the project and let people know that it is valuable. Thanks for your support! + +![FreeScout](https://freescout-helpdesk.github.io/img/screenshots/screenshot.png) + +## Table of Contents + * [Demo](#demo) + * [Features](#features) + * [Mobile Apps](#mobile-apps) + * [Requirements](#requirements) + * [Installation](#installation) + * [Cloud Hosted](#cloud-hosted) + * [Modules](#modules) + * [Tools & Integrations](#tools--integrations) + * [News & Updates](#news--updates) + * [Contributing](#contributing) + * [Screenshots](#screenshots) + +## Demo + +**[Live Demo](https://demo.freescout.net)** + +## Features + + * No limitations on the number of users, tickets, mailboxes, etc. + * 100% Mobile-friendly. + * Multilingual: English, Chinese, Croatian, Czech, Danish, Dutch, Finnish, French, German, Italian, Japanese, Korean, Norwegian, Persian, Polish, Portuguese, Russian, Spanish, Slovak, Swedish. + * Seamless email integration. + * Supports modern Microsoft Exchange authentication. + * Web installer & updater. + * Starred conversations. + * Forwarding conversations. + * Merging conversations. + * Moving conversations between mailboxes. + * Phone conversations. + * Sending new conversations to multiple recipients at once. + * Collision detection – notice is shown when two agents open the same conversation. + * Push notifications. + * Following a conversation. + * Auto reply. + * Internal notes. + * Automatic refreshing of the conversations list without the need to reload the page. + * Pasting screenshots from the clipboard into the reply area. + * Configuring notifications on a per user basis. + * Open tracking. + * Editing threads. + * Search. + * And more… + +Need anything else? Suggest features [here](https://freescout.net/request-feature/). + +## Mobile Apps + +Mobile apps support the same functionality and modules as the web version of your FreeScout installation. Both support agents and administrators can use mobile apps. + +Android App iOS App + +## Requirements + +FreeScout is a pure PHP/MySQL application, so it can be easily deployed even on a shared hosting. + + * Nginx / Apache / IIS + * PHP 7.1 - 8.2 + * MySQL 5.0+ / MariaDB 5.0+ / PostgreSQL + +There is no minimum system requirements (CPU / RAM) – FreeScout will run on any system. + +## Installation + +[Installation Guide](https://github.com/freescout-helpdesk/freescout/wiki/Installation-Guide) + +Images & one-click installs: + +* [Docker Image](http://freescout.net/docker/) +* [Softaculous](http://www.softaculous.com/apps/customersupport/FreeScout) (cPanel, Plesk, ISPmanager, H-Sphere, DirectAdmin, InterWorx) +* [Fantastico](http://ff3.netenberg.com/visitors/scripts/freescout/view) (cPanel, DirectAdmin, ISP Manager, ISP Config) +* [Cloudron](https://cloudron.io/store/net.freescout.cloudronapp.html) +* [Ubuntu](https://github.com/freescout-helpdesk/freescout/wiki/Installation-Guide#interactive-installation-bash-script-ubuntu) (bash script) + +## Cloud Hosted + +[Cloud Hosted FreeScout](https://freescout.net/cloud-hosted/) + +## Modules + +* [Official Modules](https://freescout.net/modules/) +* [Community Modules](https://freescout.net/community-modules/) + +## Tools & Integrations + + * [API](https://api-docs.freescout.net/) + * [Migrate to FreeScout](http://freescout.net/migrate/) (from any help desk) + * [Zapier](https://freescout.net/zapier/) + * [Make](https://freescout.net/make-integration/) (Integromat) + +## News & Updates + +Don't miss news, updates and new modules! + +[Email Newsletter](https://freescout.net/subscribe/) | [Facebook](https://freescout.net/facebook/) | [Twitter](https://freescout.net/twitter/) | [YouTube](https://freescout.net/youtube/) | [Telegram](https://freescout.net/telegram/) | [RSS](https://freescout.net/feed/) + +## Contributing + +* [Support the project by leaving a feedback](https://github.com/freescout-helpdesk/freescout/issues/288) +* [Development Guide](https://github.com/freescout-helpdesk/freescout/wiki/Development-Guide) +* [Todo list](https://github.com/freescout-helpdesk/freescout/labels/help%20wanted) +* [Translate](https://github.com/freescout-helpdesk/freescout/wiki/Translate) + +## Screenshots + +Dashboard: + +![Dashboard](https://freescout-helpdesk.github.io/img/screenshots/dashboard.png) + +Conversation: + +![Conversation](https://freescout-helpdesk.github.io/img/screenshots/conversation.png) + + +Mailbox connection settings page: + +![Mailbox connection settings page](https://freescout-helpdesk.github.io/img/screenshots/mailbox-connection.png) + +Notifications: + +![Notifications](https://freescout-helpdesk.github.io/img/screenshots/notifications.png) + +Push notification: + +![Push notification](https://freescout-helpdesk.github.io/img/screenshots/push.png) + +Web installer: + +![Web installer](https://freescout-helpdesk.github.io/img/screenshots/installer.png) + +Login page: + +![Login page](https://freescout-helpdesk.github.io/img/screenshots/freescout-login.png) \ No newline at end of file diff --git a/freescout-dist/SECURITY.md b/freescout-dist/SECURITY.md new file mode 100644 index 0000000..b445b39 --- /dev/null +++ b/freescout-dist/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +Email at support@freescout.net \ No newline at end of file diff --git a/freescout-dist/app/ActivityLog.php b/freescout-dist/app/ActivityLog.php new file mode 100644 index 0000000..944d014 --- /dev/null +++ b/freescout-dist/app/ActivityLog.php @@ -0,0 +1,135 @@ +description) { + case self::DESCRIPTION_USER_LOGIN: + return __('Logged in'); + case self::DESCRIPTION_USER_LOGOUT: + return __('Logged out'); + case self::DESCRIPTION_USER_REGISTER: + return __('Registered'); + case self::DESCRIPTION_USER_LOCKED: + return __('Locked out'); + case self::DESCRIPTION_USER_LOGIN_FAILED: + return __('Failed login'); + case self::DESCRIPTION_USER_PASSWORD_RESET: + return __('Reset password'); + case self::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER: + return __('Error sending email to customer'); + case self::DESCRIPTION_EMAILS_SENDING_ERROR_TO_USER: + return __('Error sending email to user'); + case self::DESCRIPTION_EMAILS_SENDING_ERROR_INVITE: + return __('Error sending invitation email to user'); + case self::DESCRIPTION_EMAILS_SENDING_ERROR_PASSWORD_CHANGED: + return __('Error sending password changed notification to user'); + case self::DESCRIPTION_EMAILS_SENDING_ERROR_ALERT: + return __('Error sending alert'); + case self::DESCRIPTION_EMAILS_SENDING_WRONG_EMAIL: + return __('Error sending email to the user who replied to notification from wrong email'); + case self::DESCRIPTION_EMAILS_FETCHING_ERROR: + return __('Error fetching email'); + case self::DESCRIPTION_SYSTEM_ERROR: + return __('System error'); + case self::DESCRIPTION_USER_DELETED: + return __('Deleted user'); + default: + return $this->description; + break; + } + } + + /** + * Get title for the log record. + */ + public static function getLogTitle($log_name) + { + switch ($log_name) { + case self::NAME_USER: + return __('Users'); + case self::NAME_OUT_EMAILS: + return __('Outgoing Emails'); + case self::NAME_EMAILS_SENDING: + return __('Send Errors'); + case self::NAME_EMAILS_FETCHING: + return __('Fetch Errors'); + case self::NAME_SYSTEM: + return __('System'); + case self::NAME_APP_LOGS: + return __('App Logs'); + default: + return ucwords(str_replace('_', ' ', $log_name)); + } + } + + public static function formatColTitle($col) + { + $col = str_replace('_', ' ', $col); + $col = ucfirst($col); + + return $col; + } + + /** + * Get log names. + * + * @return [type] [description] + */ + public static function getLogNames() + { + return self::select('log_name')->distinct()->pluck('log_name')->toArray(); + } + + /** + * Get available log names. + * + * @return [type] [description] + */ + public static function getAvailableLogs($check_existing = true) + { + $available_logs = self::$available_logs; + if ($check_existing) { + $available_logs = array_merge($available_logs, self::getLogNames()); + } + + return array_unique(\Eventy::filter('activity_log.available_logs', self::$available_logs)); + } +} diff --git a/freescout-dist/app/Attachment.php b/freescout-dist/app/Attachment.php new file mode 100644 index 0000000..61773f5 --- /dev/null +++ b/freescout-dist/app/Attachment.php @@ -0,0 +1,450 @@ + self::TYPE_MESSAGE, + 'application' => self::TYPE_APPLICATION, + 'audio' => self::TYPE_AUDIO, + 'image' => self::TYPE_IMAGE, + 'video' => self::TYPE_VIDEO, + 'model' => self::TYPE_MODEL, + 'text' => self::TYPE_TEXT, + 'multipart' => self::TYPE_MULTIPART, + 'other' => self::TYPE_OTHER, + ]; + + public static $type_extensions = [ + self::TYPE_VIDEO => ['flv', 'mp4', 'm3u8', 'ts', '3gp', 'mov', 'avi', 'wmv'] + ]; + + public $timestamps = false; + + /** + * Get thread. + */ + public function thread() + { + return $this->belongsTo('App\Thread'); + } + + /** + * Save attachment to file and database. + */ + public static function create($file_name, $mime_type, $type, $content, $uploaded_file, $embedded = false, $thread_id = null, $user_id = null) + { + if (!$content && !$uploaded_file) { + return false; + } + + // Sanitize mime type. + // https://github.com/freescout-helpdesk/freescout/issues/3048 + $mime_duplicate = strpos($mime_type, "application/vnd.openxmlformats", 1); + if ($mime_duplicate) { + $mime_type = substr($mime_type, $mime_duplicate); + } + $mime_type = substr($mime_type, 0, self::MIME_TYPE_MAX_LENGTH); + + $orig_extension = pathinfo($file_name, PATHINFO_EXTENSION); + + // Add underscore to the extension if file has restricted extension. + $file_name = \Helper::sanitizeUploadedFileName($file_name, $uploaded_file, $content); + + // Replace some symbols in file name. + // Gmail can not load image if it contains spaces. + $file_name = preg_replace('/[ #\/]/', '_', $file_name); + // Replace soft hyphens. + $file_name = str_replace(html_entity_decode('­'), '_', $file_name); + + if (!$file_name) { + if (!$orig_extension) { + preg_match("/.*\/([^\/]+)$/", $mime_type, $m); + if (!empty($m[1])) { + $orig_extension = $m[1]; + } + } + $file_name = uniqid(); + if ($orig_extension) { + $file_name .= '.'.$orig_extension; + } + } + + // https://github.com/freescout-helpdesk/freescout/issues/2385 + // Fix for webklex/php-imap. + if ($file_name == 'undefined' && $mime_type == 'message/rfc822') { + $file_name = 'RFC822.eml'; + } + + // https://github.com/freescout-helpdesk/freescout/issues/1412#issuecomment-1658881493 + if ($file_name == 'undefined' && $mime_type == 'text/calendar') { + $file_name = 'calendar.ics'; + } + + if (strlen($file_name) > 255) { + $without_ext = pathinfo($file_name, PATHINFO_FILENAME); + $extension = pathinfo($file_name, PATHINFO_EXTENSION); + // 125 because file name may have unicode symbols. + $file_name = \Helper::substrUnicode($without_ext, 0, 125-strlen($extension)-1); + $file_name .= '.'.$extension; + } + + if (!$type) { + $type = self::detectType($mime_type, $orig_extension); + } + + $attachment = new self(); + $attachment->thread_id = $thread_id; + $attachment->user_id = $user_id; + $attachment->file_name = $file_name; + $attachment->mime_type = $mime_type; + $attachment->type = $type; + $attachment->embedded = $embedded; + $attachment->save(); + + $file_info = self::saveFileToDisk($attachment, $file_name, $content, $uploaded_file); + + $attachment->file_dir = $file_info['file_dir']; + $attachment->size = Storage::disk(self::DISK)->size($file_info['file_path']); + $attachment->save(); + + return $attachment; + } + + /** + * Save file to the disk and return file_dir. + */ + public static function saveFileToDisk($attachment, $file_name, $content, $uploaded_file) + { + // Save file from content or copy file. + // We have to keep file name as is, so if file exists we create extra folder. + // Examples: 1/2/3 + $file_dir = self::generatePath($attachment->id); + + $i = 0; + do { + $i++; + $file_path = self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir.$i.DIRECTORY_SEPARATOR.$file_name; + } while (Storage::disk(self::DISK)->exists($file_path)); + + $file_dir .= $i.DIRECTORY_SEPARATOR; + + if ($uploaded_file) { + $uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name, ['disk' => self::DISK]); + } else { + Storage::disk(self::DISK)->put($file_path, $content); + } + + \Helper::sanitizeUploadedFileData($file_path, \Helper::getPrivateStorage(), $content); + + return [ + 'file_dir' => $file_dir, + 'file_path' => $file_path, + ]; + } + + /** + * Get file path. + * Examples: 1/2, 1/3. + * + * @param int $id + * + * @return string + */ + public static function generatePath($id) + { + $hash = md5($id); + + $first = -1; + $second = 0; + + for ($i = 0; $i < strlen($hash); $i++) { + if (is_numeric($hash[$i])) { + if ($first == -1) { + $first = $hash[$i]; + } else { + $second = $hash[$i]; + break; + } + } + } + if ($first == -1) { + $first = 0; + } + + return $first.DIRECTORY_SEPARATOR.$second.DIRECTORY_SEPARATOR; + } + + /** + * Detect attachment type by it's mime type. + * + * @param string $mime_type + * + * @return int + */ + public static function detectType($mime_type, $extension = '') + { + if (preg_match("/^text\//", $mime_type)) { + return self::TYPE_TEXT; + } elseif (preg_match("/^message\//", $mime_type)) { + return self::TYPE_MESSAGE; + } elseif (preg_match("/^application\//", $mime_type)) { + // This is tricky mime type. + // For .mp4 mime type can be application/octet-stream + if (!empty($extension) && in_array(strtolower($extension), self::$type_extensions[self::TYPE_VIDEO])) { + return self::TYPE_VIDEO; + } + return self::TYPE_APPLICATION; + } elseif (preg_match("/^audio\//", $mime_type)) { + return self::TYPE_AUDIO; + } elseif (preg_match("/^image\//", $mime_type)) { + return self::TYPE_IMAGE; + } elseif (preg_match("/^video\//", $mime_type)) { + return self::TYPE_VIDEO; + } elseif (preg_match("/^model\//", $mime_type)) { + return self::TYPE_MODEL; + } else { + return self::TYPE_OTHER; + } + } + + /** + * Convert type name to integer. + */ + public static function typeNameToInt($type_name) + { + if (!empty(self::$types[$type_name])) { + return self::$types[$type_name]; + } else { + return self::TYPE_OTHER; + } + } + + /** + * Get attachment full public URL. + * + * @return string + */ + public function url() + { + $file_url = Storage::url($this->getStorageFilePath()); + + // Fix percents. + // https://github.com/freescout-helpdesk/freescout/issues/3530 + $file_url = str_replace('%', '%25', $file_url); + + return $file_url.'?id='.$this->id.'&token='.$this->getToken(); + } + + /** + * Get hashed security token for the attachment. + */ + public function getToken() + { + // \Hash::make() may contain . and / symbols which may cause problems. + return md5(config('app.key').$this->id.$this->size); + } + + /** + * Outputs the current Attachment as download + */ + public function download($view = false) + { + $headers = []; + // #533 + //return $this->getDisk()->download($this->getStorageFilePath(), \Str::ascii($this->file_name)); + if ($view) { + $headers['Content-Disposition'] = ''; + } + $file_name = $this->file_name; + + if ($file_name == "RFC822"){ + $file_name = $file_name.'.eml'; + } + + return $this->getDisk()->download($this->getStorageFilePath(), $file_name, $headers); + } + + private function getDisk() { + return Storage::disk(self::DISK); + } + + /** + * Convert size into human readable format. + * + * @return string + */ + public function getSizeName() + { + return self::formatBytes($this->size); + } + + /** + * attachment/... + */ + public function getStorageFilePath() + { + return self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name; + } + + /** + * /var/html/storage/app/attachment/... + */ + public function getLocalFilePath($full = true) + { + if ($full) { + return $this->getDisk()->path(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name); + } else { + return DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name; + } + } + + /** + * Check if the attachment file actually exists on the disk. + */ + public function fileExists() + { + return $this->getDisk()->exists(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name); + } + + public static function formatBytes($size, $precision = 0) + { + $size = (int) $size; + if ($size > 0) { + $base = log($size) / log(1024); + $suffixes = [' b', ' KB', ' MB', ' GB', ' TB']; + + return round(pow(1024, $base - floor($base)), $precision).$suffixes[floor($base)]; + } else { + return $size; + } + } + + /** + * Delete attachments from disk and DB. + * Embeds are not taken into account. + * + * @param array $attachments + */ + public static function deleteByIds($attachment_ids) + { + if (!count($attachment_ids)) { + return; + } + $attachments = self::whereIn('id', $attachment_ids)->get(); + + // Delete from disk + self::deleteForever($attachments); + } + + /** + * Delete attachments by thread IDs. + */ + public static function deleteByThreadIds($thread_ids) + { + if (!count($thread_ids)) { + return; + } + $attachments = self::whereIn('thread_id', $thread_ids)->get(); + + // Delete from disk + self::deleteForever($attachments); + } + + public static function deleteForever($attachments) + { + // Delete from disk + foreach ($attachments as $attachment) { + $attachment->getDisk()->delete($attachment->getStorageFilePath()); + } + + // Delete from DB + self::whereIn('id', $attachments->pluck('id')->toArray())->delete(); + } + + /** + * Delete attachments and update Thread & Conversation. + */ + public static function deleteAttachments($attachments) + { + if (!$attachments instanceof \Illuminate\Support\Collection) { + $attachments = collect($attachments); + } + + foreach ($attachments as $attachment) { + if ($attachment->thread_id && $attachment->thread + && count($attachment->thread->attachments) <= 1 + ) { + $attachment->thread->has_attachments = false; + $attachment->thread->save(); + // Update conversation. + $conversation = $attachment->thread->conversation; + foreach ($conversation->threads as $thread) { + if ($thread->has_attachments) { + break 2; + } + } + $conversation->has_attachments = false; + $conversation->save(); + } + } + Attachment::deleteForever($attachments); + } + + /** + * Create a copy of the attachment and it's file. + */ + public function duplicate($thread_id = null) + { + $new_attachment = $this->replicate(); + if ($thread_id) { + $new_attachment->thread_id = $thread_id; + } + + $new_attachment->save(); + + try { + $attachment_file = new \Illuminate\Http\UploadedFile( + $this->getLocalFilePath(), $this->file_name, + null, null, true + ); + + $file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, '', $attachment_file); + + if (!empty($file_info['file_dir'])) { + $new_attachment->file_dir = $file_info['file_dir']; + $new_attachment->save(); + } + } catch (\Exception $e) { + \Helper::logException($e); + } + + return $new_attachment; + } + + public function getFileContents() + { + return $this->getDisk()->get($this->getStorageFilePath()); + } +} diff --git a/freescout-dist/app/Broadcasting/Broadcasters/PolycastBroadcaster.php b/freescout-dist/app/Broadcasting/Broadcasters/PolycastBroadcaster.php new file mode 100644 index 0000000..51e5b51 --- /dev/null +++ b/freescout-dist/app/Broadcasting/Broadcasters/PolycastBroadcaster.php @@ -0,0 +1,127 @@ +db = $app['db']; + if (\Config::get('broadcasting.connections.polycast.delete_old')) { + $this->delete_old = \Config::get('broadcasting.connections.polycast.delete_old'); + } + } + + /** + * Broadcast is called when the queued job is processed. + */ + public function broadcast(array $channels, $event, array $payload = []) + { + // delete events older than two minutes + \DB::table('polycast_events')->where('created_at', '<', Carbon::now()->subMinutes($this->delete_old)->toDateTimeString())->delete(); + + // insert the new event + \DB::table('polycast_events')->insert([ + 'channels' => json_encode($channels), + 'event' => $event, + 'payload' => json_encode($payload), + 'created_at' => Carbon::now()->toDateTimeString(), + ]); + } + + /** + * Authenticate the incoming request for a given channel. + * + * @param \Illuminate\Http\Request $request + * + * @return mixed + */ + public function auth($request) + { + // For connect request + if (empty($request->channels)) { + return true; + } + + // Check all channels + foreach ($request->channels as $channel_name => $channel_info) { + // Copied from Illuminate\Broadcasting\Broadcasters\PusherBroadcaster + if (Str::startsWith($channel_name, ['private-', 'presence-']) && + !$request->user()) { + + throw new AccessDeniedHttpException(); + } + + if (Str::startsWith($channel_name, ['private-', 'presence-'])) { + $channelName = Str::startsWith($channel_name, 'private-') + ? Str::replaceFirst('private-', '', $channel_name) + : Str::replaceFirst('presence-', '', $channel_name); + // This throws an exception if needed. + parent::verifyUserCanAccessChannel( + $request, $channelName + ); + } + } + + return true; + } + + /** + * Return the valid authentication response. + * + * @param \Illuminate\Http\Request $request + * @param mixed $result + * + * @return mixed + */ + public function validAuthenticationResponse($request, $result) + { + // By some reason this is never called + return false; + + // Copied from Illuminate\Broadcasting\Broadcasters\RedisBroadcaster + // if (is_bool($result)) { + // return json_encode($result); + // } + + // return json_encode(['channel_data' => [ + // 'user_id' => $request->user()->getAuthIdentifier(), + // 'user_info' => $result, + // ]]); + } + + public function isDeferred() + { + return false; + } + + /* + * Created as there was an error: + * "Call to undefined method App\Broadcasting\Broadcasters\PolycastBroadcaster::channel()" + * + * It is called from routes/channels.php + */ + // public function channel($channel, callable $callback) + // { + // return true; + // //return (int) $user->id === (int) $id; + // } +} diff --git a/freescout-dist/app/Channels/RealtimeBroadcastChannel.php b/freescout-dist/app/Channels/RealtimeBroadcastChannel.php new file mode 100644 index 0000000..d96c013 --- /dev/null +++ b/freescout-dist/app/Channels/RealtimeBroadcastChannel.php @@ -0,0 +1,29 @@ +getData($notifiable, $notification); + + $event = new RealtimeBroadcastNotificationCreated( + $notifiable, $notification, is_array($message) ? $message : $message->data + ); + + return $this->events->dispatch($event); + } +} diff --git a/freescout-dist/app/Console/Commands/AfterAppUpdate.php b/freescout-dist/app/Console/Commands/AfterAppUpdate.php new file mode 100644 index 0000000..48dcc69 --- /dev/null +++ b/freescout-dist/app/Console/Commands/AfterAppUpdate.php @@ -0,0 +1,44 @@ +call('freescout:clear-cache'); + $this->call('migrate', ['--force' => true]); + $this->call('queue:restart'); + } +} diff --git a/freescout-dist/app/Console/Commands/Build.php b/freescout-dist/app/Console/Commands/Build.php new file mode 100644 index 0000000..f7f97e9 --- /dev/null +++ b/freescout-dist/app/Console/Commands/Build.php @@ -0,0 +1,43 @@ +call('freescout:generate-vars'); + $this->call('laroute:generate'); + } +} diff --git a/freescout-dist/app/Console/Commands/CheckConvViewers.php b/freescout-dist/app/Console/Commands/CheckConvViewers.php new file mode 100644 index 0000000..b41f388 --- /dev/null +++ b/freescout-dist/app/Console/Commands/CheckConvViewers.php @@ -0,0 +1,117 @@ + $conv_data) { + if (empty($conv_data) || !is_array($conv_data)) { + continue; + } + foreach ($conv_data as $user_id => $data) { + + if (!isset($data['t']) || !isset($data['r'])) { + continue; + } + + $view_date = Carbon::createFromFormat('Y-m-d H:i:s', $data['t']); + + if ($view_date && $now->diffInSeconds($view_date) > 25) { + // Remove user from viewers. + unset($cache_data[$conversation_id][$user_id]); + if (empty($cache_data[$conversation_id])) { + unset($cache_data[$conversation_id]); + } + $need_update = true; + + \Cache::forget('conv_view_'.$user_id.'_'.$conversation_id); + + // Create event to let other users know that user finished viewing conversation. + $notification_data = [ + 'conversation_id' => $conversation_id, + 'user_id' => $user_id, + ]; + event(new \App\Events\RealtimeConvViewFinish($notification_data)); + + \Eventy::action('conversation.view.finish', $conversation_id, $user_id, $now->diffInSeconds($view_date)); + } + } + } + + if ($need_update) { + // Update conversation cache data. + \Cache::put($cache_key, $cache_data, 20 /*minutes*/); + } + /*$cache_key = 'conv_view_'.$this->user_id.'_'.$this->conversation_id; + $cache_data = \Cache::get($cache_key); + + if (!isset($cache_data['t']) || !isset($cache_data['r'])) { + return; + } + + $view_date = Carbon::createFromFormat('Y-m-d H:i:s', $cache_data['t']); + $now = Carbon::now(); + + if ($view_date && $now->diffInSeconds($view_date) > 30) { + $cache_key = 'conv_view'; + if (!empty($cache_data[$this->conversation_id]) && !empty($cache_data[$this->conversation_id][$this->user_id])) { + // Remove user from viewers. + unset($cache_data[$this->conversation_id][$this->user_id]); + + // Update conversation cache data. + \Cache::put($cache_key, $cache_data, 1); + } + + // Create event to let other users know that user finished viewing conversation. + $notification_data = [ + 'conversation_id' => $conversation->id, + 'user_id' => $user->id, + ]; + event(new \App\Events\RealtimeConvViewFinish($notification_data)); + }*/ + } +} diff --git a/freescout-dist/app/Console/Commands/CheckRequirements.php b/freescout-dist/app/Console/Commands/CheckRequirements.php new file mode 100644 index 0000000..84fbf6f --- /dev/null +++ b/freescout-dist/app/Console/Commands/CheckRequirements.php @@ -0,0 +1,63 @@ +comment("PHP Version"); + $this->line(' '.str_pad(phpversion(), 30, '.'). ' '.(version_compare(phpversion(), config('installer.core.minPhpVersion'), '>=') ? 'OK' : 'NOT FOUND'), false); + + $this->comment("PHP Extensions"); + $this->output($php_extensions); + + // Functions. + $functions = \Helper::checkRequiredFunctions(); + + $this->comment("Functions"); + $this->output($functions); + $this->line(''); + } + + public function output($items) + { + foreach ($items as $item => $status) { + $this->line(' '.str_pad($item, 30, '.'). ' '.($status ? 'OK' : 'NOT FOUND'), false); + } + } +} diff --git a/freescout-dist/app/Console/Commands/CleanNotificationsTable.php b/freescout-dist/app/Console/Commands/CleanNotificationsTable.php new file mode 100644 index 0000000..35ebed8 --- /dev/null +++ b/freescout-dist/app/Console/Commands/CleanNotificationsTable.php @@ -0,0 +1,48 @@ +where('created_at', '<', \Carbon\Carbon::now()->modify(self::PERIOD)) + ->whereNotNull('read_at') + ->delete(); + + $this->info('['.date('Y-m-d H:i:s').'] Deleted old read notifications for: '.self::PERIOD); + } +} diff --git a/freescout-dist/app/Console/Commands/CleanSendLog.php b/freescout-dist/app/Console/Commands/CleanSendLog.php new file mode 100644 index 0000000..6deaa1f --- /dev/null +++ b/freescout-dist/app/Console/Commands/CleanSendLog.php @@ -0,0 +1,46 @@ +modify(self::PERIOD))->delete(); + + $this->info('['.date('Y-m-d H:i:s').'] Deleted send logs: '.self::PERIOD); + } +} diff --git a/freescout-dist/app/Console/Commands/CleanTmp.php b/freescout-dist/app/Console/Commands/CleanTmp.php new file mode 100644 index 0000000..9999f5d --- /dev/null +++ b/freescout-dist/app/Console/Commands/CleanTmp.php @@ -0,0 +1,46 @@ +comment("Done"); + } +} diff --git a/freescout-dist/app/Console/Commands/ClearCache.php b/freescout-dist/app/Console/Commands/ClearCache.php new file mode 100644 index 0000000..2c6adc2 --- /dev/null +++ b/freescout-dist/app/Console/Commands/ClearCache.php @@ -0,0 +1,62 @@ +call('clear-compiled'); + $this->call('cache:clear'); + $this->call('view:clear'); + if ($this->option('doNotCacheConfig')) { + $this->call('config:clear'); + } else { + $this->call('config:cache'); + // Laravel users `require` function to include config.php + // If opcache is being used for few seconds config.php is being cached. + if (function_exists('opcache_invalidate')) { + opcache_invalidate(app()->getCachedConfigPath()); + } + } + // Regenerate vars to get new data from .env + if (!$this->option('doNotGenerateVars')) { + $this->call('freescout:generate-vars'); + } + // This should not be done during installation. + if (\Helper::isInstalled()) { + \Helper::queueWorkerRestart(); + } + } +} diff --git a/freescout-dist/app/Console/Commands/CreateUser.php b/freescout-dist/app/Console/Commands/CreateUser.php new file mode 100644 index 0000000..b7de6be --- /dev/null +++ b/freescout-dist/app/Console/Commands/CreateUser.php @@ -0,0 +1,102 @@ +role = $this->option('role'); + if ($user->role ) { + if (!in_array($user->role , User::$roles)) { + $this->error('Invalid role'); + return false; + } + } else { + $user->role = $this->ask('User role (admin/user)', 'admin'); + while (!in_array($user->role, User::$roles)) { + $this->error('Invalid role'); + $user->role = $this->ask('Please enter valid role'); + } + } + $user->role = array_flip(User::$roles)[$user->role]; + + $user->first_name = $this->option('firstName') ? $this->option('firstName') : $this->ask('User first name'); + $user->last_name = $this->option('lastName') ? $this->option('lastName') : $this->ask('User last name'); + + $user->email = $this->option('email'); + if ($user->email) { + if (!filter_var($user->email, FILTER_VALIDATE_EMAIL)) { + $this->error('Invalid email address'); + return false; + } + } else { + $user->email = $this->ask('User email address'); + while (!filter_var($user->email, FILTER_VALIDATE_EMAIL)) { + $this->error('Incorrect email address'); + $user->email = $this->ask('Please enter valid email address'); + } + } + + $user->password = \Hash::make($this->option('password') ? $this->option('password') : $this->secret('User password')); + + if ($this->confirm('Do you want to create the user?', true)) { + if ($user->isAdmin()) { + $user->invite_state = User::INVITE_STATE_ACTIVATED; + } + + try { + $user->save(); + } catch (\Exception $e) { + $this->line($e->getMessage()); + $this->error('User already exists.'); + return false; + } + } + + $this->info('User created with id: '.$user->id); + + return true; + } +} diff --git a/freescout-dist/app/Console/Commands/FetchEmails.php b/freescout-dist/app/Console/Commands/FetchEmails.php new file mode 100644 index 0000000..3b68f30 --- /dev/null +++ b/freescout-dist/app/Console/Commands/FetchEmails.php @@ -0,0 +1,1503 @@ +line('['.date('Y-m-d H:i:s').'] Fetching '.($this->option('unseen') ? 'UNREAD' : 'ALL').' emails for the last '.$this->option('days').' days.'); + + $this->extra_import = []; + + if (Mailbox::getInProtocols() === Mailbox::$in_protocols) { + $this->mailboxes = Mailbox::get(); + } else { + // Get active mailboxes with the default in_protocols + $this->mailboxes = Mailbox::whereIn('in_protocol', array_keys(Mailbox::$in_protocols))->get(); + } + + // https://github.com/freescout-helpdesk/freescout/issues/2563 + // Add small delay between connections to avoid blocking by mail servers, + // especially when there many mailboxes. + // Microseconds: 1 second = 1 000 000 microseconds. + $sleep = 20000; + + foreach ($this->mailboxes as $mailbox) { + if (!$mailbox->isInActive()) { + continue; + } + + $sleep += 20000; + if ($sleep > 500000) { + $sleep = 500000; + } + + $this->info('['.date('Y-m-d H:i:s').'] Mailbox: '.$mailbox->name); + + $this->mailbox = $mailbox; + + try { + $this->fetch($mailbox); + } catch (\Exception $e) { + $successfully = false; + $this->logError('Error: '.$e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')').')'; + } + + usleep($sleep); + } + + // Import emails sent to several mailboxes at once. + if (count($this->extra_import)) { + $this->line('['.date('Y-m-d H:i:s').'] Importing emails sent to several mailboxes at once: '.count($this->extra_import)); + foreach ($this->extra_import as $i => $extra_import) { + $this->line('['.date('Y-m-d H:i:s').'] '.($i+1).') '.$extra_import['message']->getSubject()); + $this->processMessage($extra_import['message'], $extra_import['message_id'], $extra_import['mailbox'], [], true); + } + } + + if ($successfully && count($this->mailboxes)) { + Option::set('fetch_emails_last_successful_run', $now); + } + + // Middleware Terminate handler is not launched for commands, + // so we need to run processing subscription events manually + Subscription::processEvents(); + + $this->info('['.date('Y-m-d H:i:s').'] Fetching finished'); + + $this->extra_import = []; + $this->mailbox = null; + $this->mailboxes = []; + } + + public function fetch($mailbox) + { + $no_charset = false; + + $client = \MailHelper::getMailboxClient($mailbox); + + // Connect to the Server + $client->connect(); + + $folders = []; + + // Fetch emails from custom IMAP folders. + //if ($mailbox->in_protocol == Mailbox::IN_PROTOCOL_IMAP) { + $imap_folders = $mailbox->getInImapFolders(); + + foreach ($imap_folders as $folder_name) { + $folder = null; + try { + $folder = \MailHelper::getImapFolder($client, $folder_name); + } catch (\Exception $e) { + // Just log error and continue. + $this->error('['.date('Y-m-d H:i:s').'] Could not get mailbox IMAP folder: '.$folder_name); + } + + if ($folder) { + $folders[] = $folder; + } + } + // try { + // //$folders = $client->getFolders(); + // } catch (\Exception $e) { + // // Do nothing + // } + + $unseen = \Eventy::filter('fetch_emails.unseen', $this->option('unseen'), $mailbox); + if ($unseen != $this->option('unseen')) { + $this->line('['.date('Y-m-d H:i:s').'] Fetching: '.($unseen ? 'UNREAD' : 'ALL')); + } + + foreach ($folders as $folder) { + $this->line('['.date('Y-m-d H:i:s').'] Folder: '.$folder->name); + + // Requesting emails by bunches allows to fetch large amounts of emails + // without problems with memory. + $page = 0; + do { + // Get messages. + $last_error = ''; + $messages = collect([]); + + try { + $messages_query = $folder->query()->since(now()->subDays($this->option('days')))->leaveUnread(); + if ($unseen) { + $messages_query->unseen(); + } + if ($no_charset) { + $messages_query->setCharset(null); + } + $messages_query->limit(self::PAGE_SIZE, $page); + + $messages = $messages_query->get(); + + if (method_exists($client, 'getLastError')) { + $last_error = $client->getLastError(); + } + } catch (\Exception $e) { + $last_error = $e->getMessage(); + } + + if ($last_error && stristr($last_error, 'The specified charset is not supported')) { + $errors_count = count($client->getErrors()); + // Solution for MS mailboxes. + // https://github.com/freescout-helpdesk/freescout/issues/176 + $messages_query = $folder->query()->since(now()->subDays($this->option('days')))->leaveUnread()->setCharset(null); + if ($unseen) { + $messages_query->unseen(); + } + $messages = $messages_query->get(); + + $no_charset = true; + if (count($client->getErrors()) > $errors_count) { + $last_error = $client->getLastError(); + } else { + $last_error = null; + } + } + + if ($last_error && !\Str::startsWith($last_error, 'Mailbox is empty')) { + // Throw exception for INBOX only + if ($folder->name == 'INBOX' && !$messages) { + throw new \Exception($last_error, 1); + } else { + $this->error('['.date('Y-m-d H:i:s').'] '.$last_error); + $this->logError('Folder: '.$folder->name.'; Error: '.$last_error); + } + } + + $this->line('['.date('Y-m-d H:i:s').'] Fetched: '.count($messages)); + + $message_index = 1; + + // We have to sort messages manually, as they can be in non-chronological order + $messages = $this->sortMessage($messages); + foreach ($messages as $message_id => $message) { + $this->line('['.date('Y-m-d H:i:s').'] '.$message_index.') '.$message->getSubject()); + $message_index++; + + $dest_mailbox = \Eventy::filter('fetch_emails.mailbox_to_save_message', $mailbox, $folder); + $this->processMessage($message, $message_id, $dest_mailbox, $this->mailboxes); + } + $page++; + } while (count($messages) == self::PAGE_SIZE); + } + + $client->disconnect(); + } + + public function processMessage($message, $message_id, $mailbox, $mailboxes, $extra = false) + { + try { + + // From - $from is the plain text email. + $from = $message->getReplyTo(); + + if (!$from + // https://github.com/freescout-helpdesk/freescout/issues/3101 + || !($reply_to = $this->formatEmailList($from)) + || empty($reply_to[0]) + || preg_match('/^.+@unknown$/', $reply_to[0]) + ) { + $from = $message->getFrom(); + } + // https://github.com/freescout-helpdesk/freescout/issues/2833 + /*else { + // If this is an auto-responder do not use Reply-To as sender email. + // https://github.com/freescout-helpdesk/freescout/issues/2826 + $headers = $this->headerToStr($message->getHeader()); + if (\MailHelper::isAutoResponder($headers)) { + $from = $message->getFrom(); + } + }*/ + + if ($from) { + $from = $this->formatEmailList($from); + } + + if (!$from) { + $this->logError('From is empty'); + $this->setSeen($message, $mailbox); + return; + } else { + $from = $from[0]; + } + + // Message-ID can be empty. + // https://stackoverflow.com/questions/8513165/php-imap-do-emails-have-to-have-a-messageid + if (!$message_id) { + // Generate artificial Message-ID. + $message_id = \MailHelper::generateMessageId($from, $message->getRawBody()); + $this->line('['.date('Y-m-d H:i:s').'] Message-ID is empty, generated artificial Message-ID: '.$message_id); + } + + $duplicate_message_id = false; + + // Special hack to allow threading into conversations Jira messages. + // https://github.com/freescout-helpdesk/freescout/issues/2927 + // + // Jira does not properly populate Reference / In-Reply-To headers. + // When Jira sends a reply the In-Reply-To header is set to: + // JIRA.$\{issue-id}.$\{issue-created-date-millis}@$\{host} + // + // If we see the first message of a ticket we change the Message-ID, + // so all follow-ups in the ticket are nicely threaded. + $jira_message_id = preg_replace('/^(JIRA\.\d+\.\d+)\..*(@Atlassian.JIRA)/', '\1\2', $message_id); + if ($jira_message_id != $message_id) { + if (!Thread::where('message_id', $jira_message_id)->exists()) { + $message_id = $jira_message_id; + } + } + + if (!$extra) { + $duplicate_message_id = Thread::where('message_id', $message_id)->first(); + } + + // Mailbox has been mentioned in Bcc. + if (!$extra && $duplicate_message_id) { + + $recipients = array_merge( + $this->formatEmailList($message->getTo()), + $this->formatEmailList($message->getCc()) + ); + + if (!in_array(Email::sanitizeEmail($mailbox->email), $recipients) + // Make sure that previous email has been imported into other mailbox. + && $duplicate_message_id->conversation + && $duplicate_message_id->conversation->mailbox_id != $mailbox->id + ) { + $extra = true; + $duplicate_message_id = null; + } + } + + // Gnerate artificial Message-ID if importing same email into several mailboxes. + if ($extra) { + // Generate artificial Message-ID. + $message_id = \MailHelper::generateMessageId(strstr($message_id, '@') ? $message_id : $from, $mailbox->id.$message_id); + $this->line('['.date('Y-m-d H:i:s').'] Generated artificial Message-ID: '.$message_id); + } + + // Check if message already fetched. + if ($duplicate_message_id) { + $this->line('['.date('Y-m-d H:i:s').'] Message with such Message-ID has been fetched before: '.$message_id); + $this->setSeen($message, $mailbox); + return; + } + + // Detect prev thread + $is_reply = false; + $prev_thread = null; + $user_id = null; + $user = null; // for user reply only + $message_from_customer = true; + $in_reply_to = $message->getInReplyTo(); + $references = $message->getReferences(); + $attachments = $message->getAttachments(); + $html_body = ''; + + // Is it a bounce message + $is_bounce = false; + + // Determine previous Message-ID + $prev_message_id = ''; + if ($in_reply_to) { + $prev_message_id = trim($in_reply_to, '<>'); + } elseif ($references) { + if (!is_array($references)) { + $references = array_filter(preg_split('/[, <>]/', $references)); + } + // Find first non-empty reference + if (is_array($references)) { + foreach ($references as $reference) { + if (!empty(trim($reference))) { + $prev_message_id = trim($reference); + break; + } + } + } + } + + // Some mail service providers change Message-ID of the outgoing email, + // so we are passing Message-ID in marker in body. + $reply_prefixes = [ + \MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION, + \MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER, + \MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY, + ]; + + // Try to get previous message ID from marker in body. + if (!$prev_message_id || !preg_match('/^('.implode('|', $reply_prefixes).')\-(\d+)\-/', $prev_message_id)) { + $html_body = $message->getHTMLBody(false); + $marker_message_id = \MailHelper::fetchMessageMarkerValue($html_body); + + if ($marker_message_id) { + $prev_message_id = $marker_message_id; + } + } + + // Bounce detection. + $bounced_message_id = null; + if ($message->hasAttachments()) { + // Detect bounce by attachment. + // Check all attachments. + foreach ($attachments as $attachment) { + if (!empty(Attachment::$types[$attachment->getType()]) && Attachment::$types[$attachment->getType()] == Attachment::TYPE_MESSAGE + ) { + if ( + // Checking the name will lead to mistakes if someone attaches a file with such name. + // Dashes are converted to space. + //in_array(strtoupper($attachment->getName()), ['RFC822', 'DELIVERY STATUS', 'DELIVERY STATUS NOTIFICATION', 'UNDELIVERED MESSAGE']) + preg_match('/delivery-status/', strtolower($attachment->content_type)) + // 7.3.1 The Message/rfc822 (primary) subtype. A Content-Type of "message/rfc822" indicates that the body contains an encapsulated message, with the syntax of an RFC 822 message + //|| $attachment->content_type == 'message/rfc822' + ) { + $is_bounce = true; + + $this->line('['.date('Y-m-d H:i:s').'] Bounce detected by attachment content-type: '.$attachment->content_type); + + // Try to get Message-ID of the original email. + if (!$bounced_message_id) { + //print_r(\MailHelper::parseHeaders($attachment->getContent())); + $bounced_message_id = \MailHelper::getHeader($attachment->getContent(), 'message_id'); + } + } + } + } + } + $message_header = $this->headerToStr($message->getHeader()); + + // Check Content-Type header. + if (!$is_bounce && $message_header) { + if (\MailHelper::detectBounceByHeaders($message_header)) { + $is_bounce = true; + } + } + // Check message's From field. + if (!$is_bounce) { + if ($message->getFrom()) { + $original_from = $this->formatEmailList($message->getFrom()); + $original_from = $original_from[0]; + $is_bounce = preg_match('/^mailer\-daemon@/i', $original_from); + if ($is_bounce) { + $this->line('['.date('Y-m-d H:i:s').'] Bounce detected by From header: '.$original_from); + } + } + } + // Check Return-Path header + if (!$is_bounce && preg_match("/^Return\-Path: <>/i", $message_header)) { + $this->line('['.date('Y-m-d H:i:s').'] Bounce detected by Return-Path header.'); + $is_bounce = true; + } + + if ($is_bounce && !$bounced_message_id) { + foreach ($attachments as $attachment_msg) { + // 7.3.1 The Message/rfc822 (primary) subtype. A Content-Type of "message/rfc822" indicates that the body contains an encapsulated message, with the syntax of an RFC 822 message + if ($attachment_msg->content_type == 'message/rfc822') { + $bounced_message_id = \MailHelper::getHeader($attachment_msg->getContent(), 'message_id'); + if ($bounced_message_id) { + break; + } + } + } + } + + // Is it a message from Customer or User replied to the notification + preg_match('/^'.\MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION."\-(\d+)\-(\d+)\-/", $prev_message_id, $m); + + if (!$is_bounce && !empty($m[1]) && !empty($m[2])) { + // Reply from User to the notification + $prev_thread = Thread::find($m[1]); + $user_id = $m[2]; + $user = User::find($user_id); + $message_from_customer = false; + $is_reply = true; + + if (!$user) { + $this->logError('User not found: '.$user_id); + $this->setSeen($message, $mailbox); + return; + } + $this->line('['.date('Y-m-d H:i:s').'] Message from: User'); + } else { + // Message from Customer or User replied to his reply to notification + $this->line('['.date('Y-m-d H:i:s').'] Message from: Customer'); + + if (!$is_bounce) { + if ($prev_message_id) { + $prev_thread_id = ''; + + // Customer replied to the email from user + preg_match('/^'.\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER."\-(\d+)\-([a-z0-9]+)@/", $prev_message_id, $m); + // Simply checking thread_id from message_id was causing an issue when + // customer was sending a message from FreeScout - the message was + // connected to the wrong conversation. + if (!empty($m[1]) && !empty($m[2])) { + $message_id_hash = $m[2]; + if (strlen($message_id_hash) == 16) { + if ($message_id_hash == \MailHelper::getMessageIdHash($m[1])) { + $prev_thread_id = $m[1]; + } + } else { + // Backward compatibility. + $prev_thread_id = $m[1]; + } + } + + // Customer replied to the auto reply + if (!$prev_thread_id) { + preg_match('/^'.\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY."\-(\d+)\-([a-z0-9]+)@/", $prev_message_id, $m); + if (!empty($m[1]) && !empty($m[2])) { + $message_id_hash = $m[2]; + if (strlen($message_id_hash) == 16) { + if ($message_id_hash == \MailHelper::getMessageIdHash($m[1])) { + $prev_thread_id = $m[1]; + } + } else { + // Backward compatibility. + $prev_thread_id = $m[1]; + } + } + } + + if ($prev_thread_id) { + $prev_thread = Thread::find($prev_thread_id); + } else { + // Customer replied to his own message + $prev_thread = Thread::where('message_id', $prev_message_id)->first(); + } + + // Reply from user to his reply to the notification + if (!$prev_thread + && ($prev_thread = Thread::where('message_id', $prev_message_id)->first()) + && $prev_thread->created_by_user_id + && $prev_thread->created_by_user->hasEmail($from) + ) { + $user_id = $user->id; + $message_from_customer = false; + $is_reply = true; + } + } + if (!empty($prev_thread)) { + $is_reply = true; + } + } + } + + // Make sure that prev_thread belongs to the current mailbox. + // Problems may arise when forwarding conversation for example. + // + // For replies to email notifications it's allowed to have prev_thread in + // another mailbox as conversation can be moved. + // https://github.com/freescout-helpdesk/freescout/issues/3455 + if ($prev_thread && $message_from_customer) { + if ($prev_thread->conversation->mailbox_id != $mailbox->id) { + // https://github.com/freescout-helpdesk/freescout/issues/2807 + // Behaviour of email sent to multiple mailboxes: + // If a user from either mailbox replies, then a new conversation is created + // in the other mailbox with another new conversation ID. + // + // Try to get thread by generated message ID. + if ($in_reply_to) { + $prev_thread = Thread::where('message_id', \MailHelper::generateMessageId($in_reply_to, $mailbox->id.$in_reply_to))->first(); + + if (!$prev_thread) { + $prev_thread = null; + $is_reply = false; + } + } else { + $prev_thread = null; + $is_reply = false; + } + } + } + + // Get body + if (!$html_body) { + // Get body and do not replace :cid with images base64 + $html_body = $message->getHTMLBody(false); + } + + $is_html = true; + + if ($html_body) { + $body = $html_body; + } else { + $is_html = false; + $body = $message->getTextBody() ?? ''; + $body = htmlspecialchars($body); + } + $body = $this->separateReply($body, $is_html, $is_reply, !$message_from_customer); + + // We have to fetch absolutely all emails, even with empty body. + // if (!$body) { + // $this->logError('Message body is empty'); + // $this->setSeen($message, $mailbox); + // continue; + // } + + // Webklex/php-imap returns object instead of a string. + $subject = $message->getSubject().""; + + // Convert subject encoding + if (preg_match('/=\?[a-z\d-]+\?[BQ]\?.*\?=/i', $subject)) { + $subject = iconv_mime_decode($subject, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8'); + } + + $to = $this->formatEmailList($message->getTo()); + + $cc = $this->formatEmailList($message->getCc()); + + // It will always return an empty value as it's Bcc. + $bcc = $this->formatEmailList($message->getBcc()); + + // If existing user forwarded customer's email to the mailbox + // we are creating a new conversation as if it was sent by the customer. + if ($in_reply_to + // We should use body here, as entire HTML may contain + // email looking things. + //&& ($fwd_body = $html_body ?: $message->getTextBody()) + && $body + //&& preg_match("/^(".implode('|', \MailHelper::$fwd_prefixes)."):(.*)/i", $subject, $m) + // F:, FW:, FWD:, WG:, De: + && preg_match("/^[[:alpha:]]{1,3}:(.*)/i", $subject, $m) + // It can be just "Fwd:" + //&& !empty($m[1]) + && !$user_id && !$is_reply && !$prev_thread + // Only if the email has been sent to one mailbox. + && count($to) == 1 && count($cc) == 0 + && preg_match("/^[\s]*".self::FWD_AS_CUSTOMER_COMMAND."/su", trim(strip_tags($body))) + ) { + // Try to get "From:" from body. + $original_sender = $this->getOriginalSenderFromFwd($body); + + if ($original_sender) { + // Check if sender is the existing user. + $sender_is_user = User::nonDeleted()->where('email', $from)->exists(); + + if ($sender_is_user) { + // Substitute sender. + $from = $original_sender; + $subject = trim($m[1] ?? $subject); + $message_from_customer = true; + + // Remove @fwd from body. + $body = trim(preg_replace("/".self::FWD_AS_CUSTOMER_COMMAND."([\s<]+)/su", '$1', $body)); + } + } + } + + // Create customers + $emails = array_merge( + $this->attrToArray($message->getFrom()), + $this->attrToArray($message->getReplyTo()), + $this->attrToArray($message->getTo()), + $this->attrToArray($message->getCc()), + // It will always return an empty value as it's Bcc. + $this->attrToArray($message->getBcc()) + ); + $this->createCustomers($emails, $mailbox->getEmails()); + + $date = $this->attrToDate($message->getDate()); + + if ($date) { + $app_timezone = config('app.timezone'); + if ($app_timezone) { + $date->setTimezone($app_timezone); + } + } + $now = now(); + if (!$date || $date->greaterThan($now)) { + $date = $now; + } + + $data = \Eventy::filter('fetch_emails.data_to_save', [ + 'mailbox' => $mailbox, + 'message_id' => $message_id, + 'prev_thread' => $prev_thread, + 'from' => $from, + 'to' => $to, + 'cc' => $cc, + 'bcc' => $bcc, + 'subject' => $subject, + 'body' => $body, + 'attachments' => $attachments, + 'message' => $message, + 'is_bounce' => $is_bounce, + 'message_from_customer' => $message_from_customer, + 'user' => $user, + 'date' => $date, + ]); + + $new_thread = null; + if ($message_from_customer) { + + // We should import the message into other mailboxes even if previous thread is set. + // https://github.com/freescout-helpdesk/freescout/issues/3473 + //if (!$data['prev_thread']) { + + // Maybe this email need to be imported also into other mailbox. + + $recipient_emails = array_unique($this->formatEmailList(array_merge( + $this->attrToArray($message->getTo()), + $this->attrToArray($message->getCc()), + // It will always return an empty value as it's Bcc. + $this->attrToArray($message->getBcc()) + ))); + + if (count($mailboxes) && count($recipient_emails) > 1) { + foreach ($mailboxes as $check_mailbox) { + if ($check_mailbox->id == $mailbox->id) { + continue; + } + if (!$check_mailbox->isInActive()) { + continue; + } + foreach ($recipient_emails as $recipient_email) { + // No need to check mailbox aliases. + if (\App\Email::sanitizeEmail($check_mailbox->email) == $recipient_email) { + $this->extra_import[] = [ + 'mailbox' => $check_mailbox, + 'message' => $message, + 'message_id' => $message_id, + ]; + break; + } + } + } + } + //} + + if (\Eventy::filter('fetch_emails.should_save_thread', true, $data) !== false) { + // SendAutoReply listener will check bounce flag and will not send an auto reply if this is an auto responder. + $new_thread = $this->saveCustomerThread($mailbox, $data['message_id'], $data['prev_thread'], $data['from'], $data['to'], $data['cc'], $data['bcc'], $data['subject'], $data['body'], $data['attachments'], $data['message']->getHeader(), $data['date']); + } else { + $this->line('['.date('Y-m-d H:i:s').'] Hook fetch_emails.should_save_thread returned false. Skipping message.'); + $this->setSeen($message, $mailbox); + return; + } + } else { + // Check if From is the same as user's email. + // If not we send an email with information to the sender. + if (!$user->hasEmail($from)) { + $this->logError("Sender address {$from} does not match ".$user->getFullName()." user email: ".$user->email.". Add ".$user->email." to user's Alternate Emails in the users's profile to allow the user reply from this address."); + $this->setSeen($message, $mailbox); + + // Send "Unable to process your update email" to user + \App\Jobs\SendEmailReplyError::dispatch($from, $user, $mailbox)->onQueue('emails'); + + return; + } + + // Save user thread only if there prev_thread is set. + // https://github.com/freescout-helpdesk/freescout/issues/3455 + if (!$prev_thread) { + $this->logError("Support agent's reply to the email notification could not be processed as previous thread could not be determined."); + $this->setSeen($message, $mailbox); + + return; + } + + if (\Eventy::filter('fetch_emails.should_save_thread', true, $data) !== false) { + $new_thread = $this->saveUserThread($data['mailbox'], $data['message_id'], $data['prev_thread'], $data['user'], $data['from'], $data['to'], $data['cc'], $data['bcc'], $data['body'], $data['attachments'], $data['message']->getHeader(), $data['date']); + } else { + $this->line('['.date('Y-m-d H:i:s').'] Hook fetch_emails.should_save_thread returned false. Skipping message.'); + $this->setSeen($message, $mailbox); + return; + } + } + + if ($new_thread) { + $this->setSeen($message, $mailbox); + $this->line('['.date('Y-m-d H:i:s').'] Thread successfully created: '.$new_thread->id); + + // If it was a bounce message, save bounce data. + if ($message_from_customer && $is_bounce) { + $this->saveBounceData($new_thread, $bounced_message_id, $from); + } + } else { + $this->logError('Error occurred processing message'); + } + } catch (\Exception $e) { + $this->setSeen($message, $mailbox); + $this->logError(\Helper::formatException($e)); + } + } + + // Try to get "From:" from body. + public function getOriginalSenderFromFwd($body) + { + // https://github.com/freescout-helpdesk/freescout/issues/2672 + $body = preg_replace("/[\"']cid:/", '!', $body); + // Cut out the command, otherwise it will be recognized as an email. + $body = preg_replace("/".self::FWD_AS_CUSTOMER_COMMAND."([\s<]+)/su", '$1', $body); + + // Looks like email texts may appear in attributes: + // https://github.com/freescout-helpdesk/freescout/issues/276 + // - :test@example.org + // - + // - <test@example.org> + + preg_match("/[\"'<:;]([^\"'<:;!@\s]+@[^\"'>:&@\s]+)[\"'>:&]/", $body, $b); + + $email = $b[1] ?? ''; + // https://github.com/freescout-helpdesk/freescout/issues/2517 + $email = preg_replace("#.*<(.*)>.*#", "$1", $email); + return Email::sanitizeEmail($email); + } + + public function saveBounceData($new_thread, $bounced_message_id, $from) + { + // Try to find bounced thread by Message-ID. + $bounced_thread = null; + if ($bounced_message_id) { + $prefixes = [ + \MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER, + \MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY, + ]; + preg_match('/^('.implode('|', $prefixes).')\-(\d+)\-/', $bounced_message_id, $matches); + if (!empty($matches[2])) { + $bounced_thread = Thread::find($matches[2]); + } + } + + $status_data = [ + 'is_bounce' => true, + ]; + if ($bounced_thread) { + $status_data['bounce_for_thread'] = $bounced_thread->id; + $status_data['bounce_for_conversation'] = $bounced_thread->conversation_id; + } + + $new_thread->updateSendStatusData($status_data); + $new_thread->save(); + + // Update status of the original message and create log record. + if ($bounced_thread) { + $bounced_thread->send_status = SendLog::STATUS_DELIVERY_ERROR; + + $status_data = [ + 'bounced_by_thread' => $new_thread->id, + 'bounced_by_conversation' => $new_thread->conversation_id, + // todo. + // 'bounce_info' => [ + // ] + ]; + + $bounced_thread->updateSendStatusData($status_data); + $bounced_thread->save(); + + // Bounces can be soft and hard, for now log both as STATUS_DELIVERY_ERROR. + SendLog::log($bounced_thread->id, null, $from, SendLog::MAIL_TYPE_EMAIL_TO_CUSTOMER, SendLog::STATUS_DELIVERY_ERROR, $bounced_thread->created_by_customer_id, null, 'Message bounced'); + } + } + + public function logError($message) + { + $this->error('['.date('Y-m-d H:i:s').'] '.$message); + + $mailbox_name = ''; + if ($this->mailbox) { + $mailbox_name = $this->mailbox->name; + } + + try { + activity() + ->withProperties([ + 'error' => $message, + 'mailbox' => $mailbox_name, + ]) + ->useLog(\App\ActivityLog::NAME_EMAILS_FETCHING) + ->log(\App\ActivityLog::DESCRIPTION_EMAILS_FETCHING_ERROR); + } catch (\Exception $e) { + // Do nothing + } + } + + /** + * Save email from customer as thread. + */ + public function saveCustomerThread($mailbox, $message_id, $prev_thread, $from, $to, $cc, $bcc, $subject, $body, $attachments, $headers, $date) + { + // Fetch date & time setting. + $use_mail_date_on_fetching = config('app.use_mail_date_on_fetching'); + + // Find conversation. + $new = false; + $conversation = null; + $prev_customer_id = null; + if ($use_mail_date_on_fetching) { + $now = $date; + }else{ + $now = date('Y-m-d H:i:s'); + } + $conv_cc = $cc; + $prev_conv_cc = $conv_cc; + + // Customers are created before with email and name + $customer = Customer::create($from); + if ($prev_thread) { + $conversation = $prev_thread->conversation; + + // If reply came from another customer: change customer, add original as CC. + // If FreeScout will not change the customer, the reply will be shown + // as coming from the original customer (not the real sender) and cause confusion. + // Below after events are fired we roll customer back. + if ($conversation->customer_id != $customer->id) { + $prev_customer_id = $conversation->customer_id; + $prev_customer_email = $conversation->customer_email; + + // Do not add to CC emails from the original's BCC + if (!in_array($conversation->customer_email, $conversation->getBccArray())) { + $conv_cc[] = $conversation->customer_email; + } + $conversation->customer_id = $customer->id; + } + } else { + // Create conversation + $new = true; + + $conversation = new Conversation(); + $conversation->type = Conversation::TYPE_EMAIL; + $conversation->state = Conversation::STATE_PUBLISHED; + $conversation->subject = $subject; + $conversation->setPreview($body); + $conversation->mailbox_id = $mailbox->id; + $conversation->customer_id = $customer->id; + $conversation->created_by_customer_id = $customer->id; + $conversation->source_via = Conversation::PERSON_CUSTOMER; + $conversation->source_type = Conversation::SOURCE_TYPE_EMAIL; + $conversation->created_at = $now; + } + + $prev_has_attachments = $conversation->has_attachments; + // Update has_attachments only if email has attachments AND conversation hasn't has_attachments already set + // Prevent to set has_attachments value back to 0 if the new reply doesn't have any attachment + if (!$conversation->has_attachments && count($attachments)) { + // Later we will check which attachments are embedded. + $conversation->has_attachments = true; + } + + // Save extra recipients to CC, but do not add the mailbox itself as a CC. + $conversation->setCc(array_merge($conv_cc, array_diff($to, $mailbox->getEmails()))); + // BCC should keep BCC of the first email, + // so we change BCC only if it contains emails. + if ($bcc) { + $conversation->setBcc($bcc); + } + $conversation->customer_email = $from; + // Reply from customer makes conversation active + if ($conversation->status != Conversation::STATUS_ACTIVE) { + $conversation->status = \Eventy::filter('conversation.status_changing', Conversation::STATUS_ACTIVE, $conversation); + } + $conversation->last_reply_at = $now; + $conversation->last_reply_from = Conversation::PERSON_CUSTOMER; + // Reply from customer to deleted conversation should undelete it. + if ($conversation->state == Conversation::STATE_DELETED) { + $conversation->state = Conversation::STATE_PUBLISHED; + } + // Set folder id + $conversation->updateFolder(); + $conversation->save(); + + // Thread + $thread = new Thread(); + $thread->conversation_id = $conversation->id; + $thread->user_id = $conversation->user_id; + $thread->type = Thread::TYPE_CUSTOMER; + $thread->status = $conversation->status; + $thread->state = Thread::STATE_PUBLISHED; + $thread->message_id = $message_id; + $thread->headers = $this->headerToStr($headers); + $thread->body = $body; + $thread->from = $from; + $thread->setTo($to); + $thread->setCc($cc); + $thread->setBcc($bcc); + $thread->source_via = Thread::PERSON_CUSTOMER; + $thread->source_type = Thread::SOURCE_TYPE_EMAIL; + $thread->customer_id = $customer->id; + $thread->created_by_customer_id = $customer->id; + $thread->created_at = $now; + $thread->updated_at = $now; + if ($new) { + $thread->first = true; + } + try { + $thread->save(); + } catch (\Exception $e) { + // Could not save thread. + // https://github.com/freescout-helpdesk/freescout/issues/3186 + if ($new) { + $conversation->deleteForever(); + } + throw $e; + } + + $body_changed = false; + $saved_attachments = $this->saveAttachments($attachments, $thread->id); + if ($saved_attachments) { + $thread->has_attachments = true; + + // After attachments saved to the disk we can replace cids in body (for PLAIN and HTML body) + $thread->body = $this->replaceCidsWithAttachmentUrls($thread->body, $saved_attachments, $conversation, $prev_has_attachments); + $body_changed = true; + } + + $new_body = Thread::replaceBase64ImagesWithAttachments($thread->body); + if ($new_body != $thread->body) { + $thread->body = $new_body; + $body_changed = true; + } + + if ($body_changed) { + $thread->save(); + } + + // Update conversation here if needed. + if ($new) { + $conversation = \Eventy::filter('conversation.created_by_customer', $conversation, $thread, $customer); + } else { + $conversation = \Eventy::filter('conversation.customer_replied', $conversation, $thread, $customer); + } + // save() will check if something in the model has changed. If it hasn't it won't run a db query. + $conversation->save(); + + // Update folders counters + $conversation->mailbox->updateFoldersCounters(); + + if ($new) { + event(new CustomerCreatedConversation($conversation, $thread)); + \Eventy::action('conversation.created_by_customer', $conversation, $thread, $customer); + } else { + event(new CustomerReplied($conversation, $thread)); + \Eventy::action('conversation.customer_replied', $conversation, $thread, $customer); + } + + // Conversation customer changed + // if ($prev_customer_id) { + // event(new ConversationCustomerChanged($conversation, $prev_customer_id, $prev_customer_email, null, $customer)); + // } + + // Return original customer back. + if ($prev_customer_id) { + $conversation->customer_id = $prev_customer_id; + $conversation->customer_email = $prev_customer_email; + $conversation->setCc(array_merge($prev_conv_cc, array_diff($to, $mailbox->getEmails()))); + $conversation->save(); + } + + return $thread; + } + + /** + * Save email reply from user as thread. + */ + public function saveUserThread($mailbox, $message_id, $prev_thread, $user, $from, $to, $cc, $bcc, $body, $attachments, $headers, $date) + { + // fetch time setting. + $use_mail_date_on_fetching = config('app.use_mail_date_on_fetching'); + + $conversation = null; + if ($use_mail_date_on_fetching) { + $now = $date; + }else{ + $now = date('Y-m-d H:i:s'); + } + $user_id = $user->id; + + $conversation = $prev_thread->conversation; + // Determine assignee. + switch ($mailbox->ticket_assignee) { + case Mailbox::TICKET_ASSIGNEE_ANYONE: + $conversation->user_id = Conversation::USER_UNASSIGNED; + break; + case Mailbox::TICKET_ASSIGNEE_REPLYING_UNASSIGNED: + if (!$conversation->user_id) { + $conversation->user_id = $user_id; + } + break; + case Mailbox::TICKET_ASSIGNEE_REPLYING: + $conversation->user_id = $user_id; + break; + case Mailbox::TICKET_ASSIGNEE_KEEP_CURRENT: + // Do nothing. + break; + } + + $prev_has_attachments = $conversation->has_attachments; + if (!$conversation->has_attachments && count($attachments)) { + // Later we will check which attachments are embedded. + $conversation->has_attachments = true; + } + + // Save extra recipients to CC + $conv_cc = $conversation->getCcArray(); + $conversation->setCc(array_merge($cc, $to)); + $conversation->setBcc($bcc); + + // Respect mailbox settings for "Status After Replying + $prev_status = $conversation->status; + $conversation->status = ($mailbox->ticket_status == Mailbox::TICKET_STATUS_KEEP_CURRENT ? $conversation->status : $mailbox->ticket_status); + if ($conversation->status != $mailbox->ticket_status) { + \Eventy::action('conversation.status_changed', $conversation, $user, true, $prev_status); + } + $conversation->last_reply_at = $now; + $conversation->last_reply_from = Conversation::PERSON_USER; + $conversation->user_updated_at = $now; + // Set folder id + $conversation->updateFolder(); + $conversation->save(); + + // Update folders counters + $conversation->mailbox->updateFoldersCounters(); + + // Set CC for the thread to send user reply to CCed emails also. + if ($conv_cc) { + $cc = array_merge($cc, $conv_cc); + } + + // Thread + $thread = new Thread(); + $thread->conversation_id = $conversation->id; + $thread->user_id = $conversation->user_id; + $thread->type = Thread::TYPE_MESSAGE; + $thread->status = $conversation->status; + $thread->state = Thread::STATE_PUBLISHED; + $thread->message_id = $message_id; + $thread->headers = $this->headerToStr($headers); + $thread->body = $body; + $thread->from = $from; + // To must be customer's email + $thread->setTo([$conversation->customer_email]); + $thread->setCc($cc); + $thread->setBcc($bcc); + $thread->source_via = Thread::PERSON_USER; + $thread->source_type = Thread::SOURCE_TYPE_EMAIL; + $thread->customer_id = $conversation->customer_id; + $thread->created_by_user_id = $user_id; + $thread->created_at = $now; + $thread->updated_at = $now; + $thread->save(); + + $body_changed = false; + $saved_attachments = $this->saveAttachments($attachments, $thread->id); + if ($saved_attachments) { + $thread->has_attachments = true; + + // After attachments saved to the disk we can replace cids in body (for PLAIN and HTML body) + $thread->body = $this->replaceCidsWithAttachmentUrls($thread->body, $saved_attachments, $conversation, $prev_has_attachments); + $body_changed = true; + } + + $new_body = Thread::replaceBase64ImagesWithAttachments($thread->body); + if ($new_body != $thread->body) { + $thread->body = $new_body; + $body_changed = true; + } + + if ($body_changed) { + $thread->save(); + } + + event(new UserReplied($conversation, $thread)); + \Eventy::action('conversation.user_replied', $conversation, $thread); + + return $thread; + } + + /** + * Save attachments from email. + * + * @param array $attachments + * @param int $thread_id + * + * @return bool + */ + public function saveAttachments($email_attachments, $thread_id) + { + $created_attachments = []; + foreach ($email_attachments as $email_attachment) { + $created_attachment = Attachment::create( + $this->processAttachmentName($email_attachment->getName()), + $email_attachment->getMimeType(), + Attachment::typeNameToInt($email_attachment->getType()), + $email_attachment->getContent(), + $uploaded_file = '', + $embedded = false, + $thread_id + ); + if ($created_attachment) { + $created_attachments[] = [ + 'imap_attachment' => $email_attachment, + 'attachment' => $created_attachment, + ]; + } + } + + return $created_attachments; + } + + public function processAttachmentName($name) + { + // Fix for Webklex/laravel-imap. + // https://github.com/freescout-helpdesk/freescout/issues/2782 + if (\Str::startsWith($name, '=?')) { + $name_decoded = \imap_utf8($name); + + if ($name_decoded) { + return $name_decoded; + } + } + + return $name; + } + + /** + * Separate reply in the body. + * + * @param string $body + * + * @return string + */ + public function separateReply($body, $is_html, $is_reply, $user_reply_to_notification = false) + { + $cmp_reply_length_desc = function ($a, $b) { + if (mb_strlen($a) == mb_strlen($b)) { + return 0; + } + + return (mb_strlen($a) < mb_strlen($b)) ? -1 : 1; + }; + + $result = ''; + + if ($is_html) { + // Extract body content from HTML + // Split by + $htmls = []; + preg_match_all("/]*>(.*?)<\/html>/is", $body, $htmls); + + if (empty($htmls[0])) { + $htmls[0] = [$body]; + } + foreach ($htmls[0] as $html) { + // One body. + $dom = new \DOMDocument(); + libxml_use_internal_errors(true); + //$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + //$dom->loadHTML(\Helper::mbConvertEncodingHtmlEntities($html)); + $dom->loadHTML(\Symfony\Polyfill\Mbstring\Mbstring::mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + libxml_use_internal_errors(false); + $bodies = $dom->getElementsByTagName('body'); + if ($bodies->length == 1) { + $body_el = $bodies->item(0); + $html = $dom->saveHTML($body_el); + } + preg_match("/]*>(.*?)<\/body>/is", $html, $matches); + if (count($matches)) { + $result .= $matches[1]; + } + } + if (!$result) { + $result = $body; + } + } else { + $result = nl2br($body ?? ''); + } + + // This is reply, we need to separate reply text from old text + if ($is_reply) { + // Check all separators and choose the shortest reply + $reply_bodies = []; + + $reply_separators = Mail::$alternative_reply_separators; + + if (!empty($this->mailbox->before_reply)) { + $reply_separators[] = $this->mailbox->before_reply; + } + + // If user replied to the email notification use only predefined reply separator. + // https://github.com/freescout-helpdesk/freescout/issues/3580 + if ($user_reply_to_notification && strstr($result, \MailHelper::REPLY_SEPARATOR_NOTIFICATION)) { + $reply_separators = [\MailHelper::REPLY_SEPARATOR_NOTIFICATION]; + } + + foreach ($reply_separators as $reply_separator) { + if (\Str::startsWith($reply_separator, 'regex:')) { + $regex = preg_replace("/^regex:/", '', $reply_separator); + $parts = preg_split($regex, $result); + } else { + $parts = explode($reply_separator, $result); + } + if (count($parts) > 1) { + // Check if part contains any real text. + $text = \Helper::htmlToText($parts[0]); + $text = trim($text); + $text = preg_replace('/^\s+/mu', '', $text); + + if ($text) { + $reply_bodies[] = $parts[0]; + } + } + } + if (count($reply_bodies)) { + usort($reply_bodies, $cmp_reply_length_desc); + + return $reply_bodies[0]; + } + } + + return $result; + } + + public function replaceCidsWithAttachmentUrls($body, $attachments, $conversation, $prev_has_attachments) + { + $only_embedded_attachments = true; + + foreach ($attachments as $attachment) { + // webklex: + // [type] => image + // [content_type] => image/png + // [id] => ii_l0krlfiu0 + // [name] => 2.png + // [disposition] => inline + // [img_src] => ... + // + // php-imap: + // [content] => ... + // [type] => text + // [part_number] => 3 + // [content_type] => image/png + // [id] => ii_l0krolw00 + // [name] => 2.png + // [disposition] => Webklex\PHPIMAP\Attribute Object + // ( + // [name:protected] => content_disposition + // [values:protected] => Array + // ( + // [0] => inline + // ) + + // ) + // [img_src] => + // [size] => 2326 + if ($attachment['imap_attachment']->id && (isset($attachment['imap_attachment']->img_src) || strlen($attachment['imap_attachment']->content ?? ''))) { + $cid = 'cid:'.$attachment['imap_attachment']->id; + if (strstr($body, $cid)) { + $body = str_replace($cid, $attachment['attachment']->url(), $body); + // Set embedded flag for the attachment. + $attachment['attachment']->embedded = true; + $attachment['attachment']->save(); + } else { + $only_embedded_attachments = false; + } + } else { + $only_embedded_attachments = false; + } + } + + if ($only_embedded_attachments + && $conversation + && $conversation->has_attachments + && !$prev_has_attachments + ) { + $conversation->has_attachments = false; + $conversation->save(); + } + + return $body; + } + + /** + * Convert email object to plain emails. + * + * @param array $obj_list + * + * @return array + */ + public function formatEmailList($obj_list) + { + $plain_list = []; + + if (!$obj_list) { + return $plain_list; + } + + $obj_list = $this->attrToArray($obj_list); + + foreach ($obj_list as $item) { + $item->mail = Email::sanitizeEmail($item->mail); + if ($item->mail) { + $plain_list[] = $item->mail; + } + } + + return $plain_list; + } + + public function attrToArray($attr) + { + if (!$attr) { + return []; + } + + if (is_object($attr) && get_class($attr) == 'Webklex\PHPIMAP\Attribute') { + $attr = $attr->get(); + } + + return $attr; + } + + public function attrToDate($attr) + { + if (!$attr) { + return null; + } + + if (is_object($attr) && get_class($attr) == 'Webklex\PHPIMAP\Attribute') { + $attr = $attr->toDate(); + } + + return $attr; + } + + public function headerToStr($header) + { + if (!is_string($header)) { + $header = $header->raw; + } + return $header; + } + + /** + * We have to sort messages manually, as they can be in non-chronological order. + * + * @param Collection $messages + * + * @return Collection + */ + public function sortMessage($messages) + { + $messages = $messages->sortBy(function ($message, $key) { + $date = $message->getDate(); + if ($date) { + if (isset($message->getDate()->timestamp)) { + return $message->getDate()->timestamp; + } else { + return (string)$message->getDate(); + } + } else { + return 0; + } + }); + + return $messages; + } + + /** + * Create customers from emails. + * + * @param array $emails_data + */ + public function createCustomers($emails, $exclude_emails) + { + foreach ($emails as $item) { + // Email belongs to mailbox + // if (in_array(Email::sanitizeEmail($item->mail), $exclude_emails)) { + // continue; + // } + $data = []; + if (!empty($item->personal)) { + $name_parts = explode(' ', $item->personal, 2); + $data['first_name'] = $name_parts[0]; + if (!empty($name_parts[1])) { + $data['last_name'] = $name_parts[1]; + } + } + Customer::create($item->mail, $data); + } + } + + public function setSeen($message, $mailbox) + { + $message->setFlag(['Seen']); + \Eventy::action('fetch_emails.after_set_seen', $message, $mailbox, $this); + } +} diff --git a/freescout-dist/app/Console/Commands/FetchMonitor.php b/freescout-dist/app/Console/Commands/FetchMonitor.php new file mode 100644 index 0000000..eec2d6f --- /dev/null +++ b/freescout-dist/app/Console/Commands/FetchMonitor.php @@ -0,0 +1,73 @@ +'.$mins_ago.' minutes ago. Please check fetching logs and make sure that the following cron task is running: 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 .= '
  • '; + $text .= ''.\App\ActivityLog::getLogTitle($name).''; + $text .= '
  • '; + } + $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 + if (is_array($emails)) { + foreach ($emails as $i => $email) { + preg_match("/^(.+)\s+([^\s]+)$/", $email ?? '', $m); + if (count($m) == 3) { + $customer_name = trim($m[1]); + $email_address = trim($m[2]); + + if ($customer_name) { + preg_match("/^([^\s]+)\s+([^\s]+)$/", $customer_name, $m_customer); + $customer_data = []; + + if (count($m_customer) == 3) { + $customer_data['first_name'] = $m_customer[1]; + $customer_data['last_name'] = $m_customer[2]; + } else { + $customer_data['first_name'] = $customer_name; + } + + Customer::create($email_address, $customer_data); + } + + $emails[$i] = $email_address; + } + } + } + return \MailHelper::sanitizeEmails($emails); + } + + /** + * Get conversation URL. + * + * @return string + */ + public function url($folder_id = null, $thread_id = null, $params = []) + { + if (!$folder_id) { + $folder_id = $this->getCurrentFolder(); + } + return self::conversationUrl($this->id, $folder_id, $thread_id, $params); + } + + /** + * Static function for retrieving URL. + * + * @param [type] $id [description] + * @param [type] $folder_id [description] + * @param [type] $thread_id [description] + * @param array $params [description] + * @return [type] [description] + */ + public static function conversationUrl($id, $folder_id = null, $thread_id = null, $params = []) + { + $params = array_merge($params, ['id' => $id]); + + $params['folder_id'] = $folder_id; + + $url = route('conversations.view', $params); + + if ($thread_id) { + $url .= '#thread-'.$thread_id; + } + + return $url; + } + + /** + * Get CSS color of the status. + * + * @return string + */ + public function getStatusColor() + { + return self::$status_colors[$this->status]; + } + + /** + * Get folder ID from request or use the default one. + */ + public function getCurrentFolder($default_folder_id = null) + { + $folder_id = self::getFolderParam(); + if ($folder_id) { + return $folder_id; + } + if ($this->folder_id) { + return $this->folder_id; + } else { + return $default_folder_id; + } + } + + public static function getFolderParam() + { + if (!empty(request()->folder_id)) { + return request()->folder_id; + } elseif (!empty(Input::get('folder_id'))) { + return Input::get('folder_id'); + } + + return ''; + } + + /** + * Check if conversation can be in the folder. + */ + public function isInFolderAllowed($folder) + { + if (in_array($folder->type, Folder::$public_types)) { + return $folder->id == $this->folder_id; + } elseif ($folder->type == Folder::TYPE_MINE) { + $user = auth()->user(); + if ($user && $user->id == $folder->user_id && $this->user_id == $user->id) { + return true; + } else { + return false; + } + } else { + // todo: check ConversationFolder here + return \Eventy::filter('conversation.is_in_folder_allowed', false, $folder, $this); + } + + return false; + } + + /** + * Check if conversation is starred. + * For each user starred conversations are cached. + */ + public function isStarredByUser($user_id = null) + { + if (!$user_id) { + $user = auth()->user(); + if ($user) { + $user_id = $user->id; + } else { + return false; + } + } + $mailbox_id = $this->mailbox_id; + + // Get ids of all the conversations starred by user and cache them + if (!isset(self::$starred_conversation_ids[$mailbox_id])) { + + self::$starred_conversation_ids[$mailbox_id] = self::getUserStarredConversationIds($mailbox_id, $user_id); + } + + if (self::$starred_conversation_ids[$mailbox_id]) { + return in_array($this->id, self::$starred_conversation_ids[$mailbox_id]); + } else { + return false; + } + } + + public static function clearStarredByUserCache($user_id, $mailbox_id) + { + if (!$user_id) { + $user = auth()->user(); + if ($user) { + $user_id = $user->id; + } else { + return false; + } + } + \Cache::forget('user_starred_conversations_'.$user_id.'_'.$mailbox_id); + } + + /** + * Get IDs of the conversations starred by user. + */ + public static function getUserStarredConversationIds($mailbox_id, $user_id = null) + { + return \Cache::rememberForever('user_starred_conversations_'.$user_id.'_'.$mailbox_id, function () use ($mailbox_id, $user_id) { + // Get user's folder + $folder = Folder::select('id') + ->where('mailbox_id', $mailbox_id) + ->where('user_id', $user_id) + ->where('type', Folder::TYPE_STARRED) + ->first(); + + if ($folder) { + return ConversationFolder::where('folder_id', $folder->id) + ->pluck('conversation_id') + ->toArray(); + } else { + activity() + ->withProperties([ + 'error' => "Folder not found (mailbox_id: $mailbox_id, user_id: $user_id)", + ]) + ->useLog(\App\ActivityLog::NAME_SYSTEM) + ->log(\App\ActivityLog::DESCRIPTION_SYSTEM_ERROR); + + return []; + } + }); + } + + /** + * Get text for the assignee. + * + * @return string + */ + public function getAssigneeName($ucfirst = false, $user = null) + { + if (!$this->user_id) { + if ($ucfirst) { + return __('Anyone'); + } else { + return __('anyone'); + } + } elseif (($user && $this->user_id == $user->id) || (!$user && auth()->user() && $this->user_id == auth()->user()->id)) { + if ($ucfirst) { + return __('Me'); + } else { + return __('me'); + } + } else { + return $this->user->getFullName(); + } + } + + /** + * Get query to fetch conversations by folder. + */ + public static function getQueryByFolder($folder, $user_id) + { + // Get conversations from personal folder + if ($folder->type == Folder::TYPE_MINE) { + $query_conversations = self::where('user_id', $user_id) + ->where('mailbox_id', $folder->mailbox_id) + ->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_PENDING]) + ->where('state', self::STATE_PUBLISHED); + + // Assigned - do not show my conversations. + } elseif ($folder->type == Folder::TYPE_ASSIGNED) { + $query_conversations = $folder->conversations() + // This condition also removes from result records with user_id = null + ->where('user_id', '<>', $user_id) + ->where('state', self::STATE_PUBLISHED); + + // Starred by user conversations. + } elseif ($folder->type == Folder::TYPE_STARRED) { + $starred_conversation_ids = self::getUserStarredConversationIds($folder->mailbox_id, $user_id); + $query_conversations = self::whereIn('id', $starred_conversation_ids); + + // Conversations are connected to folder via conversation_folder table. + } elseif ($folder->isIndirect()) { + $query_conversations = self::select('conversations.*') + //->where('conversations.mailbox_id', $folder->mailbox_id) + ->join('conversation_folder', 'conversations.id', '=', 'conversation_folder.conversation_id') + ->where('conversation_folder.folder_id', $folder->id); + if ($folder->type != Folder::TYPE_DRAFTS) { + $query_conversations->where('state', self::STATE_PUBLISHED); + } + + // Deleted. + } elseif ($folder->type == Folder::TYPE_DELETED) { + $query_conversations = $folder->conversations()->where('state', self::STATE_DELETED); + + // Everything else. + } else { + $query_conversations = $folder->conversations()->where('state', self::STATE_PUBLISHED); + } + + // If show only assigned to the current user conversations. + if (!\Helper::isConsole() + && $user_id + && $user = auth()->user() + ) { + if ($user->id == $user_id + && $user->hasManageMailboxPermission($folder->mailbox_id, Mailbox::ACCESS_PERM_ASSIGNED) + ) { + $query_conversations->where('user_id', '=', $user_id); + } + } + + return \Eventy::filter('folder.conversations_query', $query_conversations, $folder, $user_id); + } + + /** + * Replace vars in signature. + * `data` contains extra info which can be used to build signature. + */ + public function getSignatureProcessed($data = [], $escape = false) + { + $replaced_text = $this->replaceTextVars( $this->mailbox->signature, $data, $escape ); + + return \Eventy::filter( 'conversation.signature_processed', $replaced_text, $this, $data, $escape ); + } + + /** + * Replace vars in the text. + */ + public function replaceTextVars($text, $data = [], $escape = false) + { + if (!\MailHelper::hasVars($text)) { + return $text; + } + + if (empty($data['user'])) { + // `user` should contain a user who replies to the conversation. + $user = auth()->user(); + if (!$user && !empty($data['thread'])) { + $user = $data['thread']->created_by_user; + } + } else { + $user = $data['user']; + } + + $data = [ + 'mailbox' => $this->mailbox, + 'conversation' => $this, + 'customer' => $this->customer_cached, + 'user' => $user, + ]; + + // Set variables + return \MailHelper::replaceMailVars($text, $data, $escape); + } + + /** + * Change conversation customer. + * Customer is changed using customer email, as each conversation has customer email. + * Method also creates line item thread if customer changed by user. + * Both by_user and by_customer can be null. + */ + public function changeCustomer($customer_email, $customer = null, $by_user = null, $by_customer = null) + { + if (!$customer) { + $email = Email::where('email', $customer_email)->first(); + if ($email) { + $customer = $email->customer; + } else { + return false; + } + } + + if (!$customer_email) { + $customer_email = $customer->getMainEmail(); + } + + $prev_customer_id = $this->customer_id; + $prev_customer_email = $this->customer_email; + + $this->customer_email = $customer_email; + $this->customer_id = $customer->id; + $this->save(); + + // Create line item thread + if ($by_user) { + $thread = new Thread(); + $thread->conversation_id = $this->id; + $thread->user_id = $this->user_id; + $thread->type = Thread::TYPE_LINEITEM; + $thread->state = Thread::STATE_PUBLISHED; + $thread->status = Thread::STATUS_NOCHANGE; + $thread->action_type = Thread::ACTION_TYPE_CUSTOMER_CHANGED; + $thread->action_data = $this->customer_email; + $thread->source_via = Thread::PERSON_USER; + $thread->source_type = Thread::SOURCE_TYPE_WEB; + $thread->customer_id = $this->customer_id; + $thread->created_by_user_id = $by_user->id; + $thread->save(); + } + + event(new ConversationCustomerChanged($this, $prev_customer_id, $prev_customer_email, $by_user, $by_customer)); + + return true; + } + + /** + * Move conversation to another mailbox. + */ + public function moveToMailbox($mailbox, $user) + { + $prev_mailbox = $this->mailbox; + + foreach ($this->folders as $folder) { + // Process indirect folders. + if (!in_array($folder->type, Folder::$indirect_types)) { + continue; + } + // Remove conversation from the folder. + $this->removeFromFolder($folder->type, $folder->user_id); + if ($folder->type == Folder::TYPE_STARRED) { + self::clearStarredByUserCache($folder->user_id, $this->mailbox_id); + } + } + + // We don't know how to replace $this->mailbox object. + $this->mailbox_id = $mailbox->id; + // Check assignee. + if ($this->user_id && !in_array($this->user_id, $mailbox->userIdsHavingAccess())) { + // Assign conversation to the user who moved it. + $this->user_id = $user->id; + } + $this->updateFolder($mailbox); + $this->save(); + + foreach ($this->folders as $folder) { + // Process indirect folders. + if (!in_array($folder->type, Folder::$indirect_types)) { + continue; + } + // If user has access to the new mailbox, + // move conversation to the same folder in the new mailbox. + if ($folder->user_id) { + if ($folder->user->hasAccessToMailbox($mailbox->id)) { + foreach ($mailbox->folders as $mailbox_folder) { + if ($mailbox_folder->type == $folder->type) { + $this->addToFolder($folder->type, $folder->user_id); + if ($folder->type == Folder::TYPE_STARRED) { + self::clearStarredByUserCache($folder->user_id, $mailbox->id); + } + break; + } + } + } + } else { + foreach ($mailbox->folders as $mailbox_folder) { + if ($mailbox_folder->type == $folder->type) { + $this->addToFolder($folder->type, $folder->user_id); + break; + } + } + } + } + + // Add record to the conversation history. + Thread::create($this, Thread::TYPE_LINEITEM, '', [ + 'created_by_user_id' => $user->id, + 'user_id' => $this->user_id, + 'state' => Thread::STATE_PUBLISHED, + 'action_type' => Thread::ACTION_TYPE_MOVED_FROM_MAILBOX, + 'source_via' => Thread::PERSON_USER, + 'source_type' => Thread::SOURCE_TYPE_WEB, + 'customer_id' => $this->customer_id, + ]); + + // Update counters. + $prev_mailbox->updateFoldersCounters(); + $mailbox->updateFoldersCounters(); + + \Eventy::action('conversation.moved', $this, $user, $prev_mailbox); + + return true; + } + /** + * Merge conversations + */ + public function mergeConversations($second_conversation, $user) + { + // Move all threads from old to new conversation. + foreach ($second_conversation->threads as $thread) { + $thread->conversation_id = $this->id; + $thread->setMeta(Thread::META_PREV_CONVERSATION, $second_conversation->id); + $thread->save(); + } + + // Add record to the new conversation. + Thread::create($this, Thread::TYPE_LINEITEM, '', [ + 'created_by_user_id' => $user->id, + 'user_id' => $this->user_id, + 'state' => Thread::STATE_PUBLISHED, + 'action_type' => Thread::ACTION_TYPE_MERGED, + 'source_via' => Thread::PERSON_USER, + 'source_type' => Thread::SOURCE_TYPE_WEB, + 'customer_id' => $this->customer_id, + 'meta' => [Thread::META_MERGED_WITH_CONV => $second_conversation->id], + ]); + + // Add record to the old conversation. + Thread::create($second_conversation, Thread::TYPE_LINEITEM, '', [ + 'created_by_user_id' => $user->id, + 'user_id' => $second_conversation->user_id, + 'state' => Thread::STATE_PUBLISHED, + 'action_type' => Thread::ACTION_TYPE_MERGED, + 'source_via' => Thread::PERSON_USER, + 'source_type' => Thread::SOURCE_TYPE_WEB, + 'customer_id' => $second_conversation->customer_id, + 'meta' => [Thread::META_MERGED_INTO_CONV => $this->id], + ]); + + if ($second_conversation->has_attachments && !$this->has_attachments) { + $this->has_attachments = true; + $this->save(); + } + + // Move star mark. + $mailbox_star_folders = Folder::where('mailbox_id', $second_conversation->mailbox_id) + ->where('type', Folder::TYPE_STARRED) + ->get(); + + $conv_star_folder_ids = ConversationFolder::select('folder_id') + ->whereIn('folder_id', $mailbox_star_folders->pluck('id')) + ->where('conversation_id', $second_conversation->id) + ->pluck('folder_id'); + + foreach ($conv_star_folder_ids as $conv_star_folder_id) { + $folder = $mailbox_star_folders->find($conv_star_folder_id); + if ($folder->user) { + $this->star($folder->user); + $second_conversation->unstar($folder->user); + } + } + + // Delete old conversation. + $second_conversation->deleteToFolder($user); + + // Update counters. + $this->mailbox->updateFoldersCounters(); + if ($this->mailbox_id != $second_conversation->mailbox_id) { + $second_conversation->mailbox->updateFoldersCounters(); + } + + \Eventy::action('conversation.merged', $this, $second_conversation, $user); + + return true; + } + + public function star($user) + { + $this->addToFolder(Folder::TYPE_STARRED, $user->id); + self::clearStarredByUserCache($user->id, $this->mailbox_id); + $this->mailbox->updateFoldersCounters(Folder::TYPE_STARRED); + } + + public function unstar($user) + { + $this->removeFromFolder(Folder::TYPE_STARRED, $user->id); + self::clearStarredByUserCache($user->id, $this->mailbox_id); + $this->mailbox->updateFoldersCounters(Folder::TYPE_STARRED); + } + + /** + * Get all users for conversations in one query. + */ + public static function loadUsers($conversations) + { + $user_ids = $conversations->pluck('user_id')->unique()->toArray(); + if (!$user_ids || (count($user_ids) == 1 && empty($user_ids[0]))) { + return; + } + + $users = User::whereIn('id', $user_ids)->get(); + if (!$users) { + return; + } + + foreach ($conversations as $conversation) { + if (empty($conversation->user_id)) { + continue; + } + foreach ($users as $user) { + if ($user->id == $conversation->user_id) { + $conversation->user = $user; + + continue 2; + } + } + } + } + + /** + * Get all customers for conversations in one query. + */ + public static function loadCustomers($conversations) + { + $ids = $conversations->pluck('customer_id')->unique()->toArray(); + if (!$ids) { + return; + } + + $customers = Customer::whereIn('id', $ids)->get(); + if (!$customers) { + return; + } + + foreach ($conversations as $conversation) { + if (empty($conversation->customer_id)) { + continue; + } + foreach ($customers as $customer) { + if ($customer->id == $conversation->customer_id) { + $conversation->customer = $customer; + + continue 2; + } + } + } + } + + /** + * Load mailboxes. + */ + public static function loadMailboxes($conversations) + { + $ids = $conversations->pluck('mailbox_id')->unique()->toArray(); + if (!$ids) { + return; + } + + $mailboxes = Mailbox::whereIn('id', $ids)->get(); + if (!$mailboxes) { + return; + } + + foreach ($conversations as $conversation) { + if (empty($conversation->mailbox_id)) { + continue; + } + foreach ($mailboxes as $mailbox) { + if ($mailbox->id == $conversation->mailbox_id) { + $conversation->mailbox = $mailbox; + + continue 2; + } + } + } + } + + public function getSubject() + { + if ($this->subject) { + return $this->subject; + } else { + return __('(no subject)'); + } + } + + /** + * Add conversation to folder via conversation_folder table. + */ + public function addToFolder($folder_type, $user_id = null) + { + // Find folder. + $folder_query = Folder::where('mailbox_id', $this->mailbox_id) + ->where('type', $folder_type); + if ($user_id) { + $folder_query->where('user_id', $user_id); + } + $folder = $folder_query->first(); + + if (!$folder) { + return false; + } + + $values = [ + 'folder_id' => $folder->id, + 'conversation_id' => $this->id, + ]; + $folder_exists = ConversationFolder::select('id')->where($values)->first(); + if (!$folder_exists) { + // This throws an exception if record exists + $this->folders()->attach($folder->id); + } + $folder->updateCounters(); + + // updateOrCreate does not create properly with ManyToMany + // $values = [ + // 'folder_id' => $folder->id, + // 'conversation_id' => $this->id, + // ]; + // ConversationFolder::updateOrCreate($values, $values); + + return true; + } + + /** + * When removing from Starred folder, don't forget to clear cache using clearStarredByUserCache() + */ + public function removeFromFolder($folder_type, $user_id = null) + { + // Find folder + $folder_query = Folder::where('mailbox_id', $this->mailbox_id) + ->where('type', $folder_type); + + if ($user_id) { + $folder_query->where('user_id', $user_id); + } + $folder = $folder_query->first(); + + if (!$folder) { + return false; + } + + $this->folders()->detach($folder->id); + $folder->updateCounters(); + + return true; + } + + /** + * Remove conversation from drafts folder if there are no draft threads in conversation. + */ + public function maybeRemoveFromDrafts() + { + $has_drafts = Thread::where('conversation_id', $this->id) + ->where('state', Thread::STATE_DRAFT) + ->select('id') + ->first(); + if (!$has_drafts) { + $this->removeFromFolder(Folder::TYPE_DRAFTS); + + return true; + } + + return false; + } + + /** + * Delete threads and everything connected to threads. + */ + public function deleteThreads() + { + $this->threads->each(function ($thread, $i) { + $thread->deleteThread(); + }); + } + + /** + * Get waiting since time for the conversation. + * + * @param [type] $folder [description] + * + * @return [type] [description] + */ + public function getWaitingSince($folder = null) + { + if (!$folder) { + $folder = $this->folder; + } + $waiting_since_field = $folder->getWaitingSinceField(); + if ($waiting_since_field) { + // For phone conversations. + if (empty($this->$waiting_since_field)) { + $waiting_since_field = 'updated_at'; + } + return \App\User::dateDiffForHumans($this->$waiting_since_field); + } else { + return ''; + } + } + + /** + * Get type name. + */ + public function getTypeName() + { + return self::typeToName($this->type); + } + + /** + * Get type name . + */ + public static function typeToName($type) + { + $name = ''; + + switch ($type) { + case self::TYPE_EMAIL: + $name = __('Email'); + break; + + case self::TYPE_PHONE: + $name = __('Phone'); + break; + + case self::TYPE_CHAT: + $name = __('Chat'); + break; + + default: + $name = \Eventy::filter('conversation.type_name', $type); + break; + } + + return $name; + } + + /** + * Get emails which should be excluded from CC and BCC. + */ + public function getExcludeArray($mailbox = null) + { + if (!$mailbox) { + $mailbox = $this->mailbox; + } + $customer_emails = [$this->customer_email]; + if (strstr($this->customer_email ?? '', ',')) { + // customer_email contains mutiple addresses (when new conversation for multiple recipients created) + $customer_emails = explode(',', $this->customer_email); + } + return array_merge($mailbox->getEmails(), $customer_emails); + } + + /** + * Is it an email conversation. + */ + public function isEmail() + { + return ($this->type == self::TYPE_EMAIL); + } + + /** + * Is it as phone conversation. + */ + public function isPhone() + { + return ($this->type == self::TYPE_PHONE); + } + + /** + * Is it as chat conversation. + */ + public function isChat() + { + return ($this->type == self::TYPE_CHAT); + } + + /** + * Get information on viewers for conversation table. + */ + public static function getViewersInfo($conversations, $fields = ['id', 'first_name', 'last_name'], $exclude_user_ids = []) + { + $viewers_cache = \Cache::get('conv_view'); + $viewers = []; + $first_user_id = null; + $user_ids = []; + foreach ($conversations as $conversation) { + if (!empty($viewers_cache[$conversation->id])) { + // Get replying viewers + foreach ($viewers_cache[$conversation->id] as $user_id => $viewer) { + if (!$first_user_id) { + $first_user_id = $user_id; + } + if (!empty($viewer['r']) && !in_array($user_id, $exclude_user_ids)) { + $viewers[$conversation->id] = [ + 'user' => null, + 'user_id' => $user_id, + 'replying' => true + ]; + $user_ids[] = $user_id; + break; + } + } + // Get first non-replying viewer + if (empty($viewers[$conversation->id]) && !in_array($user_id, $exclude_user_ids)) { + $viewers[$conversation->id] = [ + 'user' => null, + 'user_id' => $first_user_id, + 'replying' => false + ]; + $user_ids[] = $first_user_id; + } + } + } + // Get all viewing users in one query + if ($user_ids) { + $user_ids = array_unique($user_ids); + $users = User::select($fields)->whereIn('id', $user_ids)->get(); + + foreach ($viewers as $i => $viewer) { + foreach ($users as $user) { + if ($user->id == $viewer['user_id']) { + $viewers[$i]['user'] = $user; + } + } + } + } + return $viewers; + } + + public function changeState($new_state, $user = null) + { + if (!array_key_exists($new_state, self::$states)) { + return; + } + + $prev_state = $this->state; + + $this->state = $new_state; + $this->save(); + + \Eventy::action('conversation.state_changed', $this, $user, $prev_state); + } + + public function changeStatus($new_status, $user, $create_thread = true) + { + if (!array_key_exists($new_status, self::$statuses)) { + return; + } + + $prev_status = $this->status; + + $this->setStatus($new_status, $user); + $this->save(); + + // Create lineitem thread + if ($create_thread) { + $thread = new Thread(); + $thread->conversation_id = $this->id; + $thread->user_id = $this->user_id; + $thread->type = Thread::TYPE_LINEITEM; + $thread->state = Thread::STATE_PUBLISHED; + $thread->status = $this->status; + $thread->action_type = Thread::ACTION_TYPE_STATUS_CHANGED; + $thread->source_via = Thread::PERSON_USER; + // todo: this need to be changed for API + $thread->source_type = Thread::SOURCE_TYPE_WEB; + $thread->customer_id = $this->customer_id; + $thread->created_by_user_id = $user->id; + $thread->save(); + } + + event(new ConversationStatusChanged($this)); + \Eventy::action('conversation.status_changed', $this, $user, $changed_on_reply = false, $prev_status); + } + + public function changeUser($new_user_id, $user, $create_thread = true) + { + $prev_user_id = $this->user_id; + + $this->setUser($new_user_id); + $this->save(); + + if ($create_thread) { + // Create lineitem thread + $thread = new Thread(); + $thread->conversation_id = $this->id; + $thread->user_id = $this->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; + // todo: this need to be changed for API + $thread->source_type = Thread::SOURCE_TYPE_WEB; + $thread->customer_id = $this->customer_id; + $thread->created_by_user_id = $user->id; + $thread->save(); + } + + event(new ConversationUserChanged($this, $user)); + \Eventy::action('conversation.user_changed', $this, $user, $prev_user_id); + } + + public function deleteToFolder($user) + { + $folder_id = $this->getCurrentFolder(); + + $prev_state = $this->state; + $this->state = Conversation::STATE_DELETED; + $this->user_updated_at = date('Y-m-d H:i:s'); + $this->updateFolder(); + $this->save(); + + // Create lineitem thread + $thread = new Thread(); + $thread->conversation_id = $this->id; + $thread->user_id = $this->user_id; + $thread->type = Thread::TYPE_LINEITEM; + $thread->state = Thread::STATE_PUBLISHED; + $thread->status = Thread::STATUS_NOCHANGE; + $thread->action_type = Thread::ACTION_TYPE_DELETED_TICKET; + $thread->source_via = Thread::PERSON_USER; + // todo: this need to be changed for API + $thread->source_type = Thread::SOURCE_TYPE_WEB; + $thread->customer_id = $this->customer_id; + $thread->created_by_user_id = $user->id; + $thread->save(); + + // Remove conversation from drafts folder. + $this->removeFromFolder(Folder::TYPE_DRAFTS); + + // Recalculate only old and new folders + $this->mailbox->updateFoldersCounters(); + + \Eventy::action('conversation.deleted', $this, $user); + \Eventy::action('conversation.state_changed', $this, $user, $prev_state); + } + + public function deleteForever() + { + self::deleteConversationsForever([$this->id]); + } + + public static function deleteConversationsForever($conversation_ids) + { + \Eventy::action('conversations.before_delete_forever', $conversation_ids); + + //$conversation_ids = $conversations->pluck('id')->toArray(); + for ($i=0; $i < ceil(count($conversation_ids) / \Helper::IN_LIMIT); $i++) { + + $ids = array_slice($conversation_ids, $i*\Helper::IN_LIMIT, \Helper::IN_LIMIT); + + // Delete attachments. + $thread_ids = Thread::whereIn('conversation_id', $ids)->pluck('id')->toArray(); + Attachment::deleteByThreadIds($thread_ids); + + // Observers do not react on this kind of deleting. + + // Delete threads. + Thread::whereIn('conversation_id', $ids)->delete(); + + // Delete followers. + Follower::whereIn('conversation_id', $ids)->delete(); + + // Delete conversations. + Conversation::whereIn('id', $ids)->delete(); + ConversationFolder::whereIn('conversation_id', $ids)->delete(); + } + } + + /** + * Create note or reply. + */ + public function createUserThread($user, $body, $data = []) + { + // Create thread + $thread = Thread::create($this, $data['type'] ?? Thread::TYPE_MESSAGE, $body, $data, false); + $thread->source_via = Thread::PERSON_USER; + $thread->source_type = Thread::SOURCE_TYPE_WEB; + $thread->user_id = $this->user_id; + $thread->status = $this->status; + $thread->state = Thread::STATE_PUBLISHED; + $thread->customer_id = $this->customer_id; + $thread->created_by_user_id = $user->id; + $thread->edited_by_user_id = null; + $thread->edited_at = null; + $thread->body = $body; + $thread->setTo($this->customer_email); + $thread->save(); + + // Update folders counters + $this->mailbox->updateFoldersCounters(); + + if ($thread->type == Thread::TYPE_NOTE) { + event(new UserAddedNote($this, $thread)); + \Eventy::action('conversation.note_added', $this, $thread); + } else { + event(new UserReplied($this, $thread)); + \Eventy::action('conversation.user_replied', $this, $thread); + } + } + + public function forward($user, $body, $to = '', $data = [], $include_attachments = false) + { + // Create thread + $thread = Thread::create($this, $data['type'] ?? Thread::TYPE_NOTE, $body, $data, false); + $thread->source_via = Thread::PERSON_USER; + $thread->source_type = Thread::SOURCE_TYPE_WEB; + $thread->user_id = $this->user_id; + $thread->status = $this->status; + $thread->state = Thread::STATE_PUBLISHED; + $thread->customer_id = $this->customer_id; + $thread->created_by_user_id = $user->id; + $thread->edited_by_user_id = null; + $thread->edited_at = null; + $thread->body = $body; + $thread->setTo($to); + + // Create forwarded conversation. + $now = date('Y-m-d H:i:s'); + $forwarded_conversation = $this->replicate(); + $forwarded_conversation->type = Conversation::TYPE_EMAIL; + $forwarded_conversation->setPreview($thread->body); + $forwarded_conversation->created_by_user_id = $user->id; + $forwarded_conversation->source_via = Conversation::PERSON_USER; + $forwarded_conversation->source_type = Conversation::SOURCE_TYPE_WEB; + $forwarded_conversation->threads_count = 0; // Counter will be incremented in ThreadObserver. + $forwarded_customer = Customer::create($to); + $forwarded_conversation->customer_id = $forwarded_customer->id; + $forwarded_conversation->customer_email = $to; + $forwarded_conversation->subject = 'Fwd: '.$forwarded_conversation->subject; + $forwarded_conversation->setCc(array_merge(Conversation::sanitizeEmails($data['cc'] ?? []), [$to])); + $forwarded_conversation->setBcc($data['bcc'] ?? []); + $forwarded_conversation->last_reply_at = $now; + $forwarded_conversation->last_reply_from = Conversation::PERSON_USER; + $forwarded_conversation->user_updated_at = $now; + $forwarded_conversation->updateFolder(); + $forwarded_conversation->save(); + + $forwarded_thread = $thread->replicate(); + + // Set forwarding meta data. + $thread->subtype = Thread::SUBTYPE_FORWARD; + $thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_NUMBER, $forwarded_conversation->number); + $thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_ID, $forwarded_conversation->id); + + $thread->save(); + + // Save forwarded thread. + $forwarded_thread->conversation_id = $forwarded_conversation->id; + $forwarded_thread->type = Thread::TYPE_MESSAGE; + $forwarded_thread->subtype = null; + $forwarded_thread->setTo($to); + // if ($attachments_info['has_attachments']) { + // $forwarded_thread->has_attachments = true; + // } + $forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_CONVERSATION_NUMBER, $this->number); + $forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_CONVERSATION_ID, $this->id); + $forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_THREAD_ID, $thread->id); + $forwarded_thread->save(); + + // Add attachments if needed. + if ($include_attachments) { + + $replies = $this->getReplies(); + + $has_attachments = false; + foreach ($replies as $reply_thread) { + + $thread_has_attachments = false; + foreach ($reply_thread->attachments as $attachment) { + $new_attachment = $attachment->replicate(); + $new_attachment->thread_id = $forwarded_thread->id; + // We need to copy attachment file, because conversations + // can be deleted along with attachments. + $new_attachment->save(); + + try { + $attachment_file = new \Illuminate\Http\UploadedFile( + $attachment->getLocalFilePath(), $attachment->file_name, + null, null, true + ); + + $file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, '', $attachment_file); + + if (!empty($file_info['file_dir'])) { + $new_attachment->file_dir = $file_info['file_dir']; + $new_attachment->save(); + + $has_attachments = true; + $thread_has_attachments = true; + } + } catch (\Exception $e) { + \Helper::logException($e); + } + } + if ($thread_has_attachments) { + $forwarded_thread->has_attachments = true; + $forwarded_thread->save(); + } + } + if ($has_attachments) { + $forwarded_conversation->has_attachments = true; + $forwarded_conversation->save(); + } + } + + // Update folders counters + $this->mailbox->updateFoldersCounters(); + + // Notifications to users not sent. + event(new UserAddedNote($this, $thread)); + // To send email with forwarded conversation. + event(new UserReplied($forwarded_conversation, $forwarded_thread)); + \Eventy::action('conversation.user_forwarded', $this, $thread, $forwarded_conversation, $forwarded_thread); + } + + // public function getEmailHistoryCode() + // { + // return self::$email_history_codes[(int)$this->email_history] ?? 'global'; + // } + + public static function getEmailHistoryName($code) { + $label = ''; + + switch ($code) { + case 'global': + $label = __('Default'); + $label .= ' ('.self::getEmailHistoryName(config('app.email_conv_history')).')'; + break; + case 'none': + $label = __('Do not include previous messages'); + break; + case 'last': + $label = __('Include the last message'); + break; + case 'full': + $label = __('Send full conversation history'); + break; + } + + return $label; + } + + /** + * Create conversation. + * + * $threads should go from old to new. + */ + public static function create($data, $threads, $customer) + { + // Detect source_via. + $source_via = $data['source_via'] ?? 0; + if (!$source_via && !empty($threads[0])) { + if (!empty($threads[0]['type']) && $threads[0]['type'] == Thread::TYPE_CUSTOMER) { + $source_via = self::PERSON_CUSTOMER; + } else { + $source_via = self::PERSON_USER; + } + } + + $conversation = new Conversation(); + $conversation->type = $data['type']; + $conversation->subject = $data['subject']; + $conversation->mailbox_id = $data['mailbox_id']; + $conversation->source_via = $source_via; + $conversation->source_type = $data['source_type']; + $conversation->customer_id = $customer->id; + $conversation->customer_email = $customer->getMainEmail().''; + $conversation->state = $data['state'] ?? Conversation::STATE_PUBLISHED; + $conversation->imported = (int)($data['imported'] ?? false); + $conversation->closed_at = $data['closed_at'] ?? null; + $conversation->channel = $data['channel'] ?? null; + $conversation->preview = ''; + + // Phone conversation is always pending. + if ($conversation->isPhone()) { + $conversation->status = Conversation::STATUS_PENDING; + } + + // Set assignee + $conversation->user_id = null; + if (!empty($data['user_id'])) { + $user_assignee = User::find($data['user_id']); + if ($user_assignee) { + $conversation->user_id = $user_assignee->id; + } + } + + $conversation->updateFolder(); + $conversation->save(); + + // Create threads. + $threads = array_reverse($threads); + $thread_created = false; + $last_customer_id = null; + $thread_result = null; + foreach ($threads as $thread) { + + $thread['conversation_id'] = $conversation->id; + + if ($conversation->imported) { + $thread['imported'] = true; + } + if (!empty($data['status'])) { + $thread['status'] = $data['status']; + } + + $thread_result = Thread::createExtended($thread, $conversation, $customer, false); + if ($thread_result) { + $thread_created = true; + } + } + + // If no threads created, delete conversation + if (!$thread_created) { + $conversation->delete(); + return false; + } + + // Restore customer if needed. + // if ($last_customer_id && $last_customer_id != $customer->id) { + // // Otherwise it does not save. + // $conversation = $conversation->fresh(); + // $conversation->customer_id = $customer->id; + // $conversation->customer_email = $customer->getMainEmail(); + // $conversation->save(); + // } + + // Update folders counters + $conversation->mailbox->updateFoldersCounters(); + + return [ + 'conversation' => $conversation, + 'thread' => $thread_result + ]; + } + + public function getChannelName() + { + return self::channelCodeToName($this->channel); + } + + public static function channelCodeToName($channel) + { + return \Eventy::filter('channel.name', '', $channel); + } + + public static function subjectFromText($text) + { + return \Helper::textPreview($text, self::SUBJECT_LENGTH); + } + + public static function refreshConversations($conversation, $thread) + { + \App\Events\RealtimeConvNewThread::dispatchSelf($thread); + \App\Events\RealtimeMailboxNewThread::dispatchSelf($conversation->mailbox_id); + \App\Events\RealtimeChat::dispatchSelf($conversation->mailbox_id); + } + + public static function getConvTableSorting($request = null) + { + if (!$request) { + $request = request(); + } + + $result = [ + 'sort_by' => 'date', + 'order' => 'desc', + ]; + + if ( + !empty($request->sorting['sort_by']) && !empty($request->sorting['order']) && + in_array($request->sorting['sort_by'], ['subject', 'number', 'date']) && + in_array($request->sorting['order'], ['asc', 'desc']) + ) { + $result['sort_by'] = $request->sorting['sort_by']; + $result['order'] = $request->sorting['order']; + } + + return $result; + } + + public static function search($q, $filters, $user = null, $query_conversations = null) + { + $mailbox_ids = []; + + // Like is case insensitive. + $like = '%'.mb_strtolower($q).'%'; + + if (!$query_conversations) { + $query_conversations = Conversation::select('conversations.*'); + } + + // https://github.com/laravel/framework/issues/21242 + // https://github.com/laravel/framework/pull/27675 + $query_conversations->groupby('conversations.id'); + + if (!empty($filters['mailbox'])) { + // Check if the user has access to the mailbox. + if ($user->hasAccessToMailbox($filters['mailbox'])) { + $mailbox_ids[] = $filters['mailbox']; + } else { + unset($filters['mailbox']); + $mailbox_ids = $user->mailboxesIdsCanView(); + } + } else { + // Get IDs of mailboxes to which user has access + $mailbox_ids = $user->mailboxesIdsCanView(); + } + + $query_conversations->whereIn('conversations.mailbox_id', $mailbox_ids); + + $like_op = 'like'; + if (\Helper::isPgSql()) { + $like_op = 'ilike'; + } + + if ($q) { + $query_conversations->where(function ($query) use ($like, $filters, $q, $like_op) { + $query->where('conversations.subject', $like_op, $like) + ->orWhere('conversations.customer_email', $like_op, $like) + ->orWhere('conversations.'.self::numberFieldName(), (int)$q) + ->orWhere('conversations.id', (int)$q) + ->orWhere('customers.first_name', $like_op, $like) + ->orWhere('customers.last_name', $like_op, $like) + ->orWhere('threads.body', $like_op, $like) + ->orWhere('threads.from', $like_op, $like) + ->orWhere('threads.to', $like_op, $like) + ->orWhere('threads.cc', $like_op, $like) + ->orWhere('threads.bcc', $like_op, $like); + + $query = \Eventy::filter('search.conversations.or_where', $query, $filters, $q); + }); + } + + // Apply search filters. + if (!empty($filters['assigned'])) { + if ($filters['assigned'] == self::USER_UNASSIGNED) { + $filters['assigned'] = null; + } + $query_conversations->where('conversations.user_id', $filters['assigned']); + } + if (!empty($filters['customer'])) { + $customer_id = $filters['customer']; + $query_conversations->where(function ($query) use ($customer_id) { + $query->where('conversations.customer_id', '=', $customer_id) + ->orWhere('threads.created_by_customer_id', '=', $customer_id); + }); + } + if (!empty($filters['status'])) { + if (count($filters['status']) == 1) { + // = is faster than IN. + $query_conversations->where('conversations.status', '=', $filters['status'][0]); + } else { + $query_conversations->whereIn('conversations.status', $filters['status']); + } + } + if (!empty($filters['state'])) { + if (count($filters['state']) == 1) { + // = is faster than IN. + $query_conversations->where('conversations.state', '=', $filters['state'][0]); + } else { + $query_conversations->whereIn('conversations.state', $filters['state']); + } + } + if (!empty($filters['subject'])) { + $query_conversations->where('conversations.subject', $like_op, '%'.mb_strtolower($filters['subject']).'%'); + } + if (!empty($filters['attachments'])) { + $has_attachments = ($filters['attachments'] == 'yes' ? true : false); + $query_conversations->where('conversations.has_attachments', '=', $has_attachments); + } + if (!empty($filters['type'])) { + $query_conversations->where('conversations.type', '=', $filters['type']); + } + if (!empty($filters['body'])) { + $query_conversations->where('threads.body', $like_op, '%'.mb_strtolower($filters['body']).'%'); + } + if (!empty($filters['number'])) { + $query_conversations->where('conversations.'.self::numberFieldName(), '=', $filters['number']); + } + if (!empty($filters['following'])) { + if ($filters['following'] == 'yes') { + $query_conversations->join('followers', function ($join) { + $join->on('followers.conversation_id', '=', 'conversations.id'); + $join->where('followers.user_id', auth()->user()->id); + }); + } + } + if (!empty($filters['id'])) { + $query_conversations->where('conversations.id', '=', $filters['id']); + } + if (!empty($filters['after'])) { + $query_conversations->where('conversations.created_at', '>=', date('Y-m-d 00:00:00', strtotime($filters['after']))); + } + if (!empty($filters['before'])) { + $query_conversations->where('conversations.created_at', '<=', date('Y-m-d 23:59:59', strtotime($filters['before']))); + } + + // Join tables if needed + $query_sql = $query_conversations->toSql(); + if (!strstr($query_sql, '`threads`.`conversation_id`')) { + $query_conversations->join('threads', function ($join) { + $join->on('conversations.id', '=', 'threads.conversation_id'); + }); + } + + if (!strstr($query_sql, '`customers`.`id`')) { + $query_conversations->leftJoin('customers', 'conversations.customer_id', '=' ,'customers.id'); + } + + $query_conversations = \Eventy::filter('search.conversations.apply_filters', $query_conversations, $filters, $q); + + $sorting = Conversation::getConvTableSorting(); + if ($sorting['sort_by'] == 'date') { + $sorting['sort_by'] = 'last_reply_at'; + } + $query_conversations->orderBy($sorting['sort_by'], $sorting['order']); + + return $query_conversations; + } + + public function getNumberAttribute($value) + { + if (self::$custom_number_cache === null) { + self::$custom_number_cache = config('app.custom_number'); + } + if (self::$custom_number_cache) { + return $value; + } else { + return $this->id; + } + } + + public static function numberFieldName() + { + if (self::$custom_number_cache === null) { + self::$custom_number_cache = config('app.custom_number'); + } + if (self::$custom_number_cache) { + return 'number'; + } else { + return 'id'; + } + } + + /** + * Get meta value. + */ + public function getMeta($key, $default = null) + { + if (isset($this->meta[$key])) { + return $this->meta[$key]; + } else { + return $default; + } + } + + /** + * Set meta value. + */ + public function setMeta($key, $value, $save = false) + { + $meta = $this->meta; + $meta[$key] = $value; + $this->meta = $meta; + + if ($save) { + $this->save(); + } + } + + public static function updatePreview($conversation_id) + { + // Get last suitable thread. + $thread = Thread::where('conversation_id', $conversation_id) + ->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_NOTE]) + ->where('state', Thread::STATE_PUBLISHED) + ->where(function ($query) { + $query->where('subtype', null) + ->orWhere('subtype', '!=', Thread::SUBTYPE_FORWARD); + }) + ->orderBy('created_at', 'desc') + ->first(); + + if ($thread) { + $thread->conversation->setPreview($thread->body); + $thread->conversation->save(); + } + } + + public function isInChatMode() + { + return $this->isChat() && \Helper::isChatMode() && \Route::is('conversations.view'); + } + + public static function getChats($mailbox_id, $offset = 0, $limit = self::CHATS_LIST_SIZE+1) + { + $chats = Conversation::where('type', self::TYPE_CHAT) + ->where('mailbox_id', $mailbox_id) + ->where('state', self::STATE_PUBLISHED) + ->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_PENDING]) + ->orderBy('last_reply_at', 'desc') + ->offset($offset) + ->limit($limit) + ->get(); + + // Preload customers. + if (count($chats)) { + self::loadCustomers($chats); + } + + return $chats; + } +} diff --git a/freescout-dist/app/ConversationFolder.php b/freescout-dist/app/ConversationFolder.php new file mode 100644 index 0000000..b1da108 --- /dev/null +++ b/freescout-dist/app/ConversationFolder.php @@ -0,0 +1,17 @@ + 'unknown', + self::GENDER_MALE => 'male', + self::GENDER_FEMALE => 'female', + ]; + + /** + * Phone types. + */ + const PHONE_TYPE_WORK = 1; + const PHONE_TYPE_HOME = 2; + const PHONE_TYPE_OTHER = 3; + const PHONE_TYPE_MOBILE = 4; + const PHONE_TYPE_FAX = 5; + const PHONE_TYPE_PAGER = 6; + + /** + * For API. + */ + public static $phone_types = [ + self::PHONE_TYPE_WORK => 'work', + self::PHONE_TYPE_HOME => 'home', + self::PHONE_TYPE_MOBILE => 'mobile', + self::PHONE_TYPE_FAX => 'fax', + self::PHONE_TYPE_PAGER => 'pager', + self::PHONE_TYPE_OTHER => 'other', + ]; + + /** + * Photo types. + */ + const PHOTO_TYPE_UKNOWN = 1; + const PHOTO_TYPE_GRAVATAR = 2; + const PHOTO_TYPE_TWITTER = 3; + const PHOTO_TYPE_FACEBOOK = 4; + const PHOTO_TYPE_GOOGLEPROFILE = 5; + const PHOTO_TYPE_GOOGLEPLUS = 6; + const PHOTO_TYPE_LINKEDIN = 7; + const PHOTO_TYPE_VK = 8; // Extra + + /** + * For API. + */ + public static $photo_types = [ + self::PHOTO_TYPE_UKNOWN => 'unknown', + self::PHOTO_TYPE_GRAVATAR => 'gravatar', + self::PHOTO_TYPE_TWITTER => 'twitter', + self::PHOTO_TYPE_FACEBOOK => 'facebook', + self::PHOTO_TYPE_GOOGLEPROFILE => 'googleprofile', + self::PHOTO_TYPE_GOOGLEPLUS => 'googleplus', + self::PHOTO_TYPE_LINKEDIN => 'linkedin', + self::PHOTO_TYPE_VK => 'vk', // Extra + ]; + + /** + * Chat types. + */ + // const CHAT_TYPE_AIM = 1; + // const CHAT_TYPE_GTALK = 2; + // const CHAT_TYPE_ICQ = 3; + // const CHAT_TYPE_XMPP = 4; + // const CHAT_TYPE_MSN = 5; + // const CHAT_TYPE_SKYPE = 6; + // const CHAT_TYPE_YAHOO = 7; + // const CHAT_TYPE_QQ = 8; + // const CHAT_TYPE_WECHAT = 10; + // const CHAT_TYPE_OTHER = 9; + + /** + * For API. + */ + // public static $chat_types = [ + // self::CHAT_TYPE_AIM => 'aim', + // self::CHAT_TYPE_GTALK => 'gtalk', + // self::CHAT_TYPE_ICQ => 'icq', + // self::CHAT_TYPE_XMPP => 'xmpp', + // self::CHAT_TYPE_MSN => 'msn', + // self::CHAT_TYPE_SKYPE => 'skype', + // self::CHAT_TYPE_YAHOO => 'yahoo', + // self::CHAT_TYPE_QQ => 'qq', + // self::CHAT_TYPE_WECHAT => 'wechat', // Extra + // self::CHAT_TYPE_OTHER => 'other', + // ]; + + // public static $chat_type_names = [ + // self::CHAT_TYPE_AIM => 'AIM', + // self::CHAT_TYPE_GTALK => 'Google+', + // self::CHAT_TYPE_ICQ => 'ICQ', + // self::CHAT_TYPE_XMPP => 'XMPP', + // self::CHAT_TYPE_MSN => 'MSN', + // self::CHAT_TYPE_SKYPE => 'Skype', + // self::CHAT_TYPE_YAHOO => 'Yahoo', + // self::CHAT_TYPE_QQ => 'QQ', + // self::CHAT_TYPE_WECHAT => 'WeChat', // Extra + // self::CHAT_TYPE_OTHER => 'Other', + // ]; + + /** + * Social types. + */ + const SOCIAL_TYPE_TWITTER = 1; + const SOCIAL_TYPE_FACEBOOK = 2; + const SOCIAL_TYPE_TELEGRAM = 14; + const SOCIAL_TYPE_LINKEDIN = 3; + const SOCIAL_TYPE_ABOUTME = 4; + const SOCIAL_TYPE_GOOGLE = 5; + const SOCIAL_TYPE_GOOGLEPLUS = 6; + const SOCIAL_TYPE_TUNGLEME = 7; + const SOCIAL_TYPE_QUORA = 8; + const SOCIAL_TYPE_FOURSQUARE = 9; + const SOCIAL_TYPE_YOUTUBE = 10; + const SOCIAL_TYPE_FLICKR = 11; + const SOCIAL_TYPE_VK = 13; // Extra + const SOCIAL_TYPE_OTHER = 12; + + public static $social_types = [ + self::SOCIAL_TYPE_TWITTER => 'twitter', + self::SOCIAL_TYPE_FACEBOOK => 'facebook', + self::SOCIAL_TYPE_TELEGRAM => 'telegram', + self::SOCIAL_TYPE_LINKEDIN => 'linkedin', + self::SOCIAL_TYPE_ABOUTME => 'aboutme', + self::SOCIAL_TYPE_GOOGLE => 'google', + self::SOCIAL_TYPE_GOOGLEPLUS => 'googleplus', + self::SOCIAL_TYPE_TUNGLEME => 'tungleme', + self::SOCIAL_TYPE_QUORA => 'quora', + self::SOCIAL_TYPE_FOURSQUARE => 'foursquare', + self::SOCIAL_TYPE_YOUTUBE => 'youtube', + self::SOCIAL_TYPE_FLICKR => 'flickr', + self::SOCIAL_TYPE_VK => 'vk', // Extra + self::SOCIAL_TYPE_OTHER => 'other', + ]; + + public static $social_type_names = [ + self::SOCIAL_TYPE_TWITTER => 'Twitter', + self::SOCIAL_TYPE_FACEBOOK => 'Facebook', + self::SOCIAL_TYPE_TELEGRAM => 'Telegram', + self::SOCIAL_TYPE_LINKEDIN => 'Linkedin', + self::SOCIAL_TYPE_ABOUTME => 'About.me', + self::SOCIAL_TYPE_GOOGLE => 'Google', + self::SOCIAL_TYPE_GOOGLEPLUS => 'Google+', + self::SOCIAL_TYPE_TUNGLEME => 'Tungle.me', + self::SOCIAL_TYPE_QUORA => 'Quora', + self::SOCIAL_TYPE_FOURSQUARE => 'Foursquare', + self::SOCIAL_TYPE_YOUTUBE => 'YouTube', + self::SOCIAL_TYPE_FLICKR => 'Flickr', + self::SOCIAL_TYPE_VK => 'VK', + self::SOCIAL_TYPE_OTHER => 'Other', + ]; + + /** + * Search filters. + */ + public static $search_filters = [ + 'mailbox', + ]; + + /** + * Countries list. + */ + public static $countries = [ + 'US' => 'United States', + 'AU' => 'Australia', + 'CA' => 'Canada', + 'DK' => 'Denmark', + 'FR' => 'France', + 'DE' => 'Germany', + 'IT' => 'Italy', + 'JP' => 'Japan', + 'MX' => 'Mexico', + 'ES' => 'Spain', + 'SE' => 'Sweden', + 'GB' => 'United Kingdom', + 'AF' => 'Afghanistan', + 'AX' => 'Åland Islands', + 'AL' => 'Albania', + 'DZ' => 'Algeria', + 'AS' => 'American Samoa', + 'AD' => 'Andorra', + 'AO' => 'Angola', + 'AI' => 'Anguilla', + 'AQ' => 'Antarctica', + 'AG' => 'Antigua and Barbuda', + 'AR' => 'Argentina', + 'AM' => 'Armenia', + 'AW' => 'Aruba', + 'AT' => 'Austria', + 'AZ' => 'Azerbaijan', + 'BS' => 'Bahamas', + 'BH' => 'Bahrain', + 'BD' => 'Bangladesh', + 'BB' => 'Barbados', + 'BY' => 'Belarus', + 'BE' => 'Belgium', + 'BZ' => 'Belize', + 'BJ' => 'Benin', + 'BM' => 'Bermuda', + 'BT' => 'Bhutan', + 'BO' => 'Bolivia', + 'BQ' => 'Bonaire', + 'BA' => 'Bosnia and Herzegowina', + 'BW' => 'Botswana', + 'BV' => 'Bouvet Island', + 'BR' => 'Brazil', + 'IO' => 'British Indian Ocean Territory', + 'BN' => 'Brunei Darussalam', + 'BG' => 'Bulgaria', + 'BF' => 'Burkina Faso', + 'BI' => 'Burundi', + 'KH' => 'Cambodia', + 'CM' => 'Cameroon', + 'CV' => 'Cape Verde', + 'KY' => 'Cayman Islands', + 'CF' => 'Central African Republic', + 'TD' => 'Chad', + 'CL' => 'Chile', + 'CN' => 'China', + 'CX' => 'Christmas Island', + 'CC' => 'Cocos (Keeling) Islands', + 'CO' => 'Colombia', + 'KM' => 'Comoros', + 'CG' => 'Congo', + 'CD' => 'Congo, DR', + 'CK' => 'Cook Islands', + 'CR' => 'Costa Rica', + 'CI' => "Cote D'Ivoire", + 'HR' => 'Croatia', + 'CU' => 'Cuba', + 'CW' => 'Curacao', + 'CY' => 'Cyprus', + 'CZ' => 'Czech Republic', + 'DJ' => 'Djibouti', + 'DM' => 'Dominica', + 'DO' => 'Dominican Republic', + 'EC' => 'Ecuador', + 'EG' => 'Egypt', + 'SV' => 'El Salvador', + 'GQ' => 'Equatorial Guinea', + 'ER' => 'Eritrea', + 'EE' => 'Estonia', + 'ET' => 'Ethiopia', + 'FK' => 'Falkland Islands (Malvinas)', + 'FO' => 'Faroe Islands', + 'FJ' => 'Fiji', + 'FI' => 'Finland', + 'GF' => 'French Guiana', + 'PF' => 'French Polynesia', + 'TF' => 'French Southern Territories', + 'GA' => 'Gabon', + 'GM' => 'Gambia', + 'GE' => 'Georgia', + 'GH' => 'Ghana', + 'GI' => 'Gibraltar', + 'GR' => 'Greece', + 'GL' => 'Greenland', + 'GD' => 'Grenada', + 'GP' => 'Guadeloupe', + 'GU' => 'Guam', + 'GT' => 'Guatemala', + 'GG' => 'Guernsey', + 'GN' => 'Guinea', + 'GW' => 'Guinea-bissau', + 'GY' => 'Guyana', + 'HT' => 'Haiti', + 'HM' => 'Heard and Mc Donald Islands', + 'HN' => 'Honduras', + 'HK' => 'Hong Kong', + 'HU' => 'Hungary', + 'IS' => 'Iceland', + 'IN' => 'India', + 'ID' => 'Indonesia', + 'IR' => 'Iran (Islamic Republic of)', + 'IQ' => 'Iraq', + 'IE' => 'Ireland', + 'IM' => 'Isle of Man', + 'IL' => 'Israel', + 'JM' => 'Jamaica', + 'JE' => 'Jersey', + 'JO' => 'Jordan', + 'KZ' => 'Kazakhstan', + 'KE' => 'Kenya', + 'KI' => 'Kiribati', + 'KP' => "Korea, Democratic People's Republic of", + 'KR' => 'Korea, Republic of', + 'KW' => 'Kuwait', + 'KG' => 'Kyrgyzstan', + 'LA' => "Lao People's Democratic Republic", + 'LV' => 'Latvia', + 'LB' => 'Lebanon', + 'LS' => 'Lesotho', + 'LR' => 'Liberia', + 'LY' => 'Libya', + 'LI' => 'Liechtenstein', + 'LT' => 'Lithuania', + 'LU' => 'Luxembourg', + 'MO' => 'Macao', + 'MK' => 'Macedonia, The Former Yugoslav Republic of', + 'MG' => 'Madagascar', + 'MW' => 'Malawi', + 'MY' => 'Malaysia', + 'MV' => 'Maldives', + 'ML' => 'Mali', + 'MT' => 'Malta', + 'MH' => 'Marshall Islands', + 'MQ' => 'Martinique', + 'MR' => 'Mauritania', + 'MU' => 'Mauritius', + 'YT' => 'Mayotte', + 'FM' => 'Micronesia, Federated States of', + 'MD' => 'Moldova, Republic of', + 'MC' => 'Monaco', + 'MN' => 'Mongolia', + 'ME' => 'Montenegro', + 'MS' => 'Montserrat', + 'MA' => 'Morocco', + 'MZ' => 'Mozambique', + 'MM' => 'Myanmar', + 'NA' => 'Namibia', + 'NR' => 'Nauru', + 'NP' => 'Nepal', + 'NL' => 'Netherlands', + 'NC' => 'New Caledonia', + 'NZ' => 'New Zealand', + 'NI' => 'Nicaragua', + 'NE' => 'Niger', + 'NG' => 'Nigeria', + 'NU' => 'Niue', + '00' => 'None Available', + 'NF' => 'Norfolk Island', + 'MP' => 'Northern Mariana Islands', + 'NO' => 'Norway', + 'OM' => 'Oman', + 'PK' => 'Pakistan', + 'PW' => 'Palau', + 'PS' => 'Palestine, State of', + 'PA' => 'Panama', + 'PG' => 'Papua New Guinea', + 'PY' => 'Paraguay', + 'PE' => 'Peru', + 'PH' => 'Philippines', + 'PN' => 'Pitcairn', + 'PL' => 'Poland', + 'PT' => 'Portugal', + 'PR' => 'Puerto Rico', + 'QA' => 'Qatar', + 'RE' => 'Reunion', + 'RO' => 'Romania', + 'RU' => 'Russia', + 'RW' => 'Rwanda', + 'BL' => 'Saint Barthelemy', + 'KN' => 'Saint Kitts and Nevis', + 'LC' => 'Saint Lucia', + 'MF' => 'Saint Martin (French part)', + 'VC' => 'Saint Vincent and the Grenadines', + 'WS' => 'Samoa', + 'SM' => 'San Marino', + 'ST' => 'Sao Tome and Principe', + 'SA' => 'Saudi Arabia', + 'SN' => 'Senegal', + 'RS' => 'Serbia', + 'SC' => 'Seychelles', + 'SL' => 'Sierra Leone', + 'SG' => 'Singapore', + 'SX' => 'Sint Maarten (Dutch part)', + 'SK' => 'Slovakia', + 'SI' => 'Slovenia', + 'SB' => 'Solomon Islands', + 'SO' => 'Somalia', + 'ZA' => 'South Africa', + 'GS' => 'South Georgia and the South Sandwich Islands', + 'SS' => 'South Sudan', + 'LK' => 'Sri Lanka', + 'SH' => 'St. Helena', + 'PM' => 'St. Pierre and Miquelon', + 'SD' => 'Sudan', + 'SR' => 'Suriname', + 'SJ' => 'Svalbard and Jan Mayen', + 'SZ' => 'Swaziland', + 'CH' => 'Switzerland', + 'SY' => 'Syrian Arab Republic', + 'TW' => 'Taiwan, Province of China', + 'TJ' => 'Tajikistan', + 'TZ' => 'Tanzania, United Republic of', + 'TH' => 'Thailand', + 'TL' => 'Timor-Leste', + 'TG' => 'Togo', + 'TK' => 'Tokelau', + 'TO' => 'Tonga', + 'TT' => 'Trinidad and Tobago', + 'TN' => 'Tunisia', + 'TR' => 'Turkey', + 'TM' => 'Turkmenistan', + 'TC' => 'Turks and Caicos Islands', + 'TV' => 'Tuvalu', + 'UG' => 'Uganda', + 'UA' => 'Ukraine', + 'AE' => 'United Arab Emirates', + 'UM' => 'United States Minor Outlying Islands', + 'UY' => 'Uruguay', + 'UZ' => 'Uzbekistan', + 'VU' => 'Vanuatu', + 'VA' => 'Vatican City State (Holy See)', + 'VE' => 'Venezuela', + 'VN' => 'Vietnam', + 'VG' => 'Virgin Islands (British)', + 'VI' => 'Virgin Islands (U.S.)', + 'WF' => 'Wallis and Futuna Islands', + 'EH' => 'Western Sahara', + 'YE' => 'Yemen', + 'ZM' => 'Zambia', + 'ZW' => 'Zimbabwe', + ]; + + protected $casts = [ + 'meta' => 'array', + ]; + + /** + * Attributes which are not fillable using fill() method. + */ + protected $guarded = ['id']; + + /** + * Attributes fillable using fill() method. + * + * @var [type] + */ + protected $fillable = ['first_name', 'last_name', 'company', 'job_title', 'address', 'city', 'state', 'zip', 'country', 'photo_url', 'age', 'gender', 'notes', 'channel', 'channel_id', 'social_profiles']; + + /** + * Fields stored as JSON. + */ + protected $json_fields = ['phones', 'websites', 'social_profiles']; + + /** + * Get customer emails. + */ + public function emails() + { + return $this->hasMany('App\Email'); + } + + /** + * Get customer emails. + */ + public function emails_cached() + { + return $this->hasMany('App\Email')->rememberForever(); + } + + /** + * Get customer conversations. + */ + public function conversations() + { + return $this->hasMany('App\Conversation'); + } + + /** + * Get main email. + */ + public function getMainEmail() + { + return optional($this->emails_cached()->first())->email.''; + } + + /** + * Get main email. + */ + public static function getMainEmailStatic($customer_id) + { + return Email::select('email')->where('customer_id', $customer_id)->pluck('email'); + } + + /** + * Get customer full name. + * + * @return string + */ + public function getFullName($email_if_empty = false, $first_part_from_email = false) + { + if ($this->first_name && $this->last_name) { + return $this->first_name.' '.$this->last_name; + } elseif (!$this->last_name && $this->first_name) { + return $this->first_name; + } elseif (!$this->first_name && $this->last_name) { + return $this->last_name; + } elseif ($email_if_empty) { + $email = $this->getMainEmail(); + if ($first_part_from_email) { + return $this->getNameFromEmail($email); + } else { + return $email; + } + } + + return ''; + } + + /** + * Get customer first name. + * + * @return string + */ + public function getFirstName($email_if_empty = false) + { + if ($this->first_name) { + return $this->first_name; + } elseif ($email_if_empty) { + return $this->getNameFromEmail(); + } + + return ''; + } + + /** + * Get first part of the email. + * + * @return string + */ + public function getNameFromEmail($email = '') + { + if (!$email) { + $email = optional($this->emails_cached()->first())->email; + } + if ($email) { + return explode('@', $email)[0]; + } else { + return ''; + } + } + + /** + * Set customer emails. + * + * @param array $emails + */ + public function syncEmails($emails) + { + if (is_array($emails)) { + $deleted_emails = []; + foreach ($this->emails as $email) { + foreach ($emails as $email_address) { + if (Email::sanitizeEmail($email->email) == Email::sanitizeEmail($email_address)) { + continue 2; + } + } + $deleted_emails[] = $email; + } + foreach ($emails as $email_address) { + $email_address = Email::sanitizeEmail($email_address); + if (!$email_address) { + continue; + } + $email = Email::where('email', $email_address)->first(); + $new_emails = []; + if ($email) { + // Assign email to current customer + if ($email->customer_id != $this->id) { + $email->customer()->associate($this); + $email->save(); + } + } else { + $new_emails[] = new Email(['email' => $email_address]); + } + if ($new_emails) { + $this->emails()->saveMany($new_emails); + } + } + + foreach ($deleted_emails as $email) { + if (Conversation::where('customer_email', $email->email)->exists()) { + // Create customers for deleted emails + // if there is a conversation with 'customer_email'. + $customer = new self(); + $customer->save(); + $email->customer()->associate($customer); + $email->save(); + } else { + // Simply delete an email. + $email->delete(); + } + } + } + } + + /** + * Add new email to customer. + */ + public function addEmail($email_address, $check_if_exists = false) + { + // Check if email already exists and belongs to another customer. + if ($check_if_exists) { + $email = Email::where('email', $email_address)->first(); + if ($email && !empty($email->customer_id)) { + return false; + } + } + $new_email = new Email(['email' => $email_address]); + $this->emails()->save($new_email); + } + + /** + * Get customers phones as array. + * + * @return array + */ + public function getPhones($dummy_if_empty = false) + { + $phones = json_decode($this->phones ?? '', true); + + if (is_array($phones) && count($phones)) { + return $phones; + } elseif ($dummy_if_empty) { + return [[ + 'value' => '', + 'type' => self::PHONE_TYPE_WORK, + ]]; + } else { + return []; + } + } + + public function getMainPhoneValue() + { + return $this->getMainPhoneNumber(); + } + + public function getMainPhoneNumber() + { + $phones = $this->getPhones(); + return $phones[0]['value'] ?? ''; + } + + /** + * Set phones as JSON. + * + * @param array $phones_array + */ + public function setPhones(array $phones_array) + { + $phones_array = self::formatPhones($phones_array); + + // Remove dubplicates. + $list = []; + foreach ($phones_array as $i => $data) { + if (in_array($data['value'], $list)) { + unset($phones_array[$i]); + } else { + $list[] = $data['value']; + } + } + + $this->phones = \Helper::jsonEncodeUtf8($phones_array); + } + + /** + * Sanitize phones array. + * + * @param array $phones_array [description] + * + * @return array [description] + */ + public static function formatPhones(array $phones_array) + { + $phones = []; + + foreach ($phones_array as $phone) { + if (is_array($phone)) { + if (!empty($phone['value'])) { + if (empty($phone['type']) || !in_array($phone['type'], array_keys(self::$phone_types))) { + $phone['type'] = self::PHONE_TYPE_WORK; + } + $phones[] = [ + 'value' => (string) $phone['value'], + 'type' => (int) $phone['type'], + 'n' => (string)\Helper::phoneToNumeric($phone['value']), + ]; + } + } else { + $phones[] = [ + 'value' => (string) $phone, + 'type' => self::PHONE_TYPE_WORK, + 'n' => (string)\Helper::phoneToNumeric($phone), + ]; + } + } + + return $phones; + } + + /** + * Add website. + */ + public function addPhone($phone, $type = self::PHONE_TYPE_WORK) + { + if (is_string($phone)) { + $this->setPhones(array_merge( + $this->getPhones(), + [['value' => $phone, 'type' => $type]] + )); + } else { + $this->setPhones(array_merge( + $this->getPhones(), + [$phone] + )); + } + } + + /** + * Find customer by phone number. + */ + public static function findByPhone($phone) + { + return Customer::byPhone($phone)->first(); + } + + /** + * Get query. + */ + public static function byPhone($phone) + { + $phone_numeric = \Helper::phoneToNumeric($phone); + return Customer::where('phones', 'LIKE', '%"'.$phone_numeric.'"%'); + } + + /** + * Get customers social profiles as array. + * + * @return array + */ + public function getSocialProfiles($dummy_if_empty = false) + { + $social_profiles = json_decode($this->social_profiles ?? '', true); + + if (is_array($social_profiles) && count($social_profiles)) { + return json_decode($this->social_profiles, true); + } elseif ($dummy_if_empty) { + return [[ + 'type' => '', + 'value' => '', + ]]; + } else { + return []; + } + } + + /** + * Get customers social profiles as array. + * + * @return array + */ + public function getWebsites($dummy_if_empty = false) + { + $websites = json_decode($this->websites ?? '', true); + if (is_array($websites) && count($websites)) { + return $websites; + } elseif ($dummy_if_empty) { + return ['']; + } else { + return []; + } + } + + public function getMainWebsite() + { + $websites = $this->getWebsites(); + return $websites[0] ?? ''; + } + + /** + * Set websites as JSON. + * + * @param array $websites_array + */ + public function setWebsites(array $websites_array) + { + $websites = []; + foreach ($websites_array as $key => $value) { + // FILTER_SANITIZE_URL cuts some symbols. + //$value = filter_var((string) $value, FILTER_SANITIZE_URL); + if (isset($value['value'])) { + $value = $value['value']; + } + if (!$value || preg_match("/^http(s)?:?\/?\/?$/i", $value)) { + continue; + } + if (!preg_match("/http(s)?:\/\//i", $value)) { + $value = 'http://'.$value; + } + $websites[] = (string) $value; + } + $this->websites = \Helper::jsonEncodeUtf8(array_unique($websites)); + } + + /** + * Add website. + */ + public function addWebsite($website) + { + $websites = $this->getWebsites(); + if (isset($website['value'])) { + $website = $website['value']; + } + array_push($websites, $website); + $this->setWebsites($websites); + } + + /** + * Sanitize social profiles. + * + * @param array $list [description] + * + * @return array [description] + */ + public static function formatSocialProfiles(array $list) + { + $social_profiles = []; + foreach ($list as $social_profile) { + if (is_array($social_profile)) { + if (!empty($social_profile['value']) && !empty($social_profile['type'])) { + + $type = null; + + if (is_numeric($social_profile['type']) && in_array($social_profile['type'], array_keys(self::$social_types))) { + $type = (int)$social_profile['type']; + } else { + // Find type. + foreach (self::$social_types as $type_id => $type_name) { + if ($type_name == strtolower($social_profile['type'])) { + $type = $type_id; + } + } + } + + if (!$type) { + continue; + } + + $social_profiles[] = [ + // Order of elements in array is important as we rely on it + // when searching customers by social profiles using "like". + 'value' => (string) $social_profile['value'], + 'type' => $type, + ]; + } + } else { + $social_profiles[] = [ + // Order of elements in array is important as we rely on it + // when searching customers by social profiles using "like". + 'value' => (string) $social_profile, + 'type' => self::SOCIAL_TYPE_OTHER, + ]; + } + } + + return $social_profiles; + } + + /** + * Set social profiles as JSON. + * + * @param array $websites_array + */ + public function setSocialProfiles(array $sp_array) + { + $sp_array = self::formatSocialProfiles($sp_array); + + // Remove dubplicates. + $list = []; + foreach ($sp_array as $i => $data) { + if (in_array($data['value'], $list)) { + unset($sp_array[$i]); + } else { + $list[] = $data['value']; + } + } + + $this->social_profiles = \Helper::jsonEncodeUtf8($sp_array); + } + + /** + * Create customer or get existing and fill empty fields. + * + * @param string $email + * @param array $data [description] + * + * @return [type] [description] + */ + public static function create($email, $data = []) + { + $new = false; + + $email = Email::sanitizeEmail($email); + if (!$email) { + return null; + } + $email_obj = Email::where('email', $email)->first(); + if ($email_obj) { + $customer = $email_obj->customer; + + // In case somehow the email has no customer. + if (!$customer) { + // Customer will be saved and connected to the email later. + $customer = new self(); + } + + // Update name if empty. + /*if (empty($customer->first_name) && !empty($data['first_name'])) { + $customer->first_name = $data['first_name']; + if (empty($customer->last_name) && !empty($data['last_name'])) { + $customer->last_name = $data['last_name']; + } + $customer->save(); + }*/ + } else { + $customer = new self(); + $email_obj = new Email(); + $email_obj->email = $email; + + $new = true; + } + + // Set empty fields + if ($customer->setData($data, false) || !$customer->id) { + $customer->save(); + } + + if (empty($email_obj->id) || !$email_obj->customer_id || $email_obj->customer_id != $customer->id) { + // Email may have been set in setData(). + $save_email = true; + if (!empty($data['emails']) && is_array($data['emails'])) { + foreach ($data['emails'] as $data_email) { + if (is_string($data_email) && $data_email == $email) { + $save_email = false; + break; + } + if (is_array($data_email) && !empty($data_email['value']) && $data_email['value'] == $email) { + $save_email = false; + break; + } + } + } + if ($save_email) { + $email_obj->customer()->associate($customer); + $email_obj->save(); + } + } + + // Todo: check phone uniqueness. + + if ($new) { + \Eventy::action('customer.created', $customer); + } + + return $customer; + } + + /** + * Set empty fields. + */ + public function setData($data, $replace_data = true, $save = false) + { + $result = false; + + // todo: photoUrl. + if (isset($data['photo_url'])) { + unset($data['photo_url']); + } + + if (!empty($data['background']) && empty($data['notes'])) { + $data['notes'] = $data['background']; + } + + if ($replace_data) { + // Replace data. + $data_prepared = $data; + foreach ($data_prepared as $i => $value) { + if (is_array($value)) { + unset($data_prepared[$i]); + } + } + $this->fill($data_prepared); + $result = true; + } else { + // Update empty fields. + foreach ($data as $key => $value) { + if (in_array($key, $this->fillable) && empty($this->$key)) { + $this->$key = $value; + $result = true; + } + } + } + + // Set JSON values. + if (!empty($data['phone'])) { + $this->addPhone($data['phone']); + } + foreach ($data as $key => $value) { + if (!in_array($key, $this->json_fields) && $key != 'emails') { + continue; + } + if ($key == 'phones') { + if (isset($value['value'])) { + $this->addPhone($value); + } else { + $this->setPhones($value); + // foreach ($value as $phone_value) { + // $this->addPhone($phone_value); + // } + } + $result = true; + } + if ($key == 'websites') { + if (is_array($value)) { + $this->setWebsites($value); + // foreach ($value as $website) { + // $this->addWebsite($website); + // } + } else { + $this->addWebsite($value); + } + $result = true; + } + if ($key == 'social_profiles') { + $this->setSocialProfiles($value); + $result = true; + } + if ($key == 'country') { + if (array_search($this->country, Customer::$countries)) { + $this->country = array_search($this->country, Customer::$countries); + } + $this->country = strtoupper(mb_substr($this->country, 0, 2)); + $result = true; + } + } + + // Emails must be processed the last as they need to save object. + foreach ($data as $key => $value) { + if ($key == 'emails') { + foreach ($value as $email_data) { + if (is_string($email_data)) { + if (!$this->id) { + $this->save(); + } + $email_created = Email::create($email_data, $this->id, Email::TYPE_WORK); + + if ($email_created) { + $result = true; + } + } elseif (!empty($email_data['value'])) { + if (!$this->id) { + $this->save(); + } + $email_created = Email::create($email_data['value'], $this->id, $email_data['type']); + + if ($email_created) { + $result = true; + } + } + } + break; + } + } + // Maybe Todo: check phone uniqueness. + // Same phone can be written in many ways, so it's almost useless to chek uniqueness. + + \Eventy::action('customer.set_data', $this, $data, $replace_data); + + if ($save) { + $this->save(); + } + + return $result; + } + + /** + * Create a customer, email is not required. + * For phone conversations. + */ + public static function createWithoutEmail($data = []) + { + $customer = new self(); + $customer->setData($data); + + $customer->save(); + + \Eventy::action('customer.created', $customer); + + return $customer; + } + + /** + * Get customer URL. + * + * @return string + */ + public function url() + { + return route('customers.update', ['id'=>$this->id]); + } + + /** + * Get view customer URL. + * + * @return string + */ + public function urlView() + { + return route('customers.conversations', ['id'=>$this->id]); + } + + /** + * Format date according to customer's timezone. + * + * @param Carbon $date + * @param string $format + * + * @return string + */ + public static function dateFormat($date, $format = 'M j, Y H:i') + { + return $date->format($format); + } + + /** + * Get full representation of customer. + */ + public function getEmailAndName() + { + // Email can be fetched using query. + $text = $this->email; + if (!$text) { + $text = $this->getMainEmail(); + } + if ($this->getFullName()) { + if ($text) { + $text .= ' ('.$this->getFullName().')'; + } else { + $text .= $this->getFullName(); + } + } + return $text; + } + + public function getNameAndEmail() + { + // Email can be fetched using query. + $text = $this->getFullName(); + $email = $this->email; + if (!$email) { + $email = $this->getMainEmail(); + } + if ($email) { + if ($text) { + $text .= ' <'.$email.'>'; + } else { + $text .= $email; + } + } + return $text; + } + + /** + * Get customers info for the list of emails. + */ + public static function emailsToCustomers($list) + { + $result = []; + + $data = Customer::select(['emails.email', 'customers.first_name', 'customers.last_name']) + ->join('emails', function ($join) { + $join->on('emails.customer_id', '=', 'customers.id'); + }) + ->whereIn('emails.email', $list) + //->groupby('customers.id') + ->get() + ->toArray(); + + foreach ($list as $email) { + // Dummy customer. + $customer = new Customer(); + $customer->email = $email; + + foreach ($data as $values) { + if (strtolower($values['email']) == strtolower($email)) { + $customer->first_name = $values['first_name']; + $customer->last_name = $values['last_name']; + break; + } + } + $result[$email] = $customer->getNameAndEmail(); + } + + return $result; + } + + /** + * Get customer by email. + */ + public static function getByEmail($email) + { + return Customer::select('customers.*') + ->where('emails.email', $email) + ->join('emails', function ($join) { + $join->on('emails.customer_id', '=', 'customers.id'); + })->first(); + } + + /** + * Get email or phone if email is empty. + */ + public function getEmailOrPhone() + { + if (!empty($this->email)) { + // Email can be selected with query. + return $this->email; + } elseif ($main_email = $this->getMainEmail()) { + return $main_email; + } elseif ($phones = $this->getPhones() && !empty($phones[0]['value'])) { + return $phones[0]['value']; + } + + return ''; + } + + public function getPhotoUrl($default_if_empty = true) + { + if (!empty($this->photo_url) || !$default_if_empty) { + if (!empty($this->photo_url)) { + return self::getPhotoUrlByFileName($this->photo_url); + } else { + return ''; + } + } else { + return \Eventy::filter('customer.default_avatar', asset('/img/default-avatar.png'), $this); + } + } + + public static function getPhotoUrlByFileName($file_name) + { + return Storage::url(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name); + } + + /** + * Resize and save photo. + */ + public function savePhoto($real_path, $mime_type) + { + $photo_size = config('app.customer_photo_size'); + $resized_image = \App\Misc\Helper::resizeImage($real_path, $mime_type, $photo_size, $photo_size); + + if (!$resized_image) { + return false; + } + + $file_name = md5(Hash::make($this->id)).'.jpg'; + $dest_path = Storage::path(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name); + + $dest_dir = pathinfo($dest_path, PATHINFO_DIRNAME); + if (!file_exists($dest_dir)) { + \File::makeDirectory($dest_dir, \Helper::DIR_PERMISSIONS); + } + + // Remove current photo + if ($this->photo_url) { + Storage::delete(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$this->photo_url); + } + + imagejpeg($resized_image, $dest_path, self::PHOTO_QUALITY); + + return $file_name; + } + + /** + * Remove user photo. + */ + public function removePhoto() + { + if ($this->photo_url) { + Storage::delete(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$this->photo_url); + } + $this->photo_url = ''; + } + + public function getCountryName() + { + if ($this->country && !empty(self::$countries[$this->country])) { + return self::$countries[$this->country]; + } else { + return ''; + } + } + + /** + * Get first and last name. + */ + public static function parseName($name) + { + $data = []; + + if (!$name) { + return $data; + } + + $name_parts = explode(' ', $name, 2); + $data['first_name'] = $name_parts[0]; + if (!empty($name_parts[1])) { + $data['last_name'] = $name_parts[1]; + } + + return $data; + } + + public static function formatSocialProfile($sp) + { + if (empty($sp['type']) || !isset(self::$social_type_names[$sp['type']])) { + $sp['type'] = self::SOCIAL_TYPE_OTHER; + } + + $sp['type_name'] = self::$social_type_names[$sp['type']]; + + $sp['value_url'] = $sp['value']; + + if (!preg_match("/^https?:\/\//i", $sp['value_url'])) { + switch ($sp['type']) { + case self::SOCIAL_TYPE_TELEGRAM: + $sp['value_url'] = 'https://t.me/'.$sp['value']; + break; + + default: + $sp['value_url'] = 'http://'.$sp['value_url']; + break; + } + } + if (empty($sp['value_url'])) { + $sp['value_url'] = ''; + } + + return $sp; + } + + public function setPhotoFromRemoteFile($url) + { + $headers = get_headers($url); + + if (!preg_match("/200/", $headers[0])) { + return false; + } + + $image_data = \Helper::getRemoteFileContents($url); + + if (!$image_data) { + return false; + } + + $temp_file = \Helper::getTempFileName(); + + \File::put($temp_file, $image_data); + + $photo_url = $this->savePhoto($temp_file, \File::mimeType($temp_file)); + + if ($photo_url) { + $this->photo_url = $photo_url; + return true; + } else { + return false; + } + } + + public function getChannelName() + { + return \Eventy::filter('channel.name', '', $this->channel); + } + + /** + * Get thread meta value. + */ + public function getMeta($key, $default = null) + { + if (isset($this->meta[$key])) { + return $this->meta[$key]; + } else { + return $default; + } + } + + /** + * Set thread meta value. + */ + public function setMeta($key, $value) + { + $meta = $this->meta; + $meta[$key] = $value; + $this->meta = $meta; + } + + public static function getPhoneTypeName($code) + { + $phone_types = [ + self::PHONE_TYPE_WORK => __('Work'), + self::PHONE_TYPE_HOME => __('Home'), + self::PHONE_TYPE_OTHER => __('Other'), + self::PHONE_TYPE_MOBILE => __('Mobile'), + self::PHONE_TYPE_FAX => __('Fax'), + self::PHONE_TYPE_PAGER => __('Pager'), + ]; + + return $phone_types[$code] ?? ''; + } + + public static function isDefaultPhoneType($code) + { + return (self::PHONE_TYPE_WORK == $code); + } + + // Method does not check if the customer + // has conversations. + public function deleteCustomer() + { + // Delete emails. + Email::where('customer_id', $this->id)->delete(); + $this->delete(); + } + + public function getChannels() + { + if (!$this->channel || !$this->channel_id) { + return collect([]); + } + return CustomerChannel::where('customer_id', $this->id)->get(); + } + + public function addChannel($channel, $channel_id) + { + // We are doing this to let existing modules not to throw error + // and as a flag that this customer has record(s) in cucstomer_channel table. + if (!$this->channel || !$this->channel_id) { + $this->channel = $channel; + $this->channel_id = $channel_id; + $this->save(); + } + + return CustomerChannel::create($this->id, $channel, $channel_id); + } + + public static function getCustomerByChannel($channel, $channel_id) + { + $customer_channel = CustomerChannel::where('channel', $channel) + ->where('channel_id', $channel_id) + ->first(); + + if ($customer_channel) { + return $customer_channel->customer; + } else { + return null; + } + } + + public function getChannelId($channel) + { + return CustomerChannel::where('customer_id', $this->id) + ->where('channel', $channel) + ->value('channel_id'); + } + + public static function findCustomersBySocialProfile($type, $value, $exclude_channel = null) + { + $value = mb_strtolower($value); + + $like = '%'.$value.'","type":'.$type.'}]'; + $customers = Customer::where('social_profiles', \Helper::sqlLikeOperator(), $like)->get(); + + // Now more prcise filtering. + foreach ($customers as $i => $customer) { + $ok = false; + foreach ($customer->getSocialProfiles() as $social_profile) { + if ($social_profile['type'] == $type + // Try to check username written in different ways: + // - username + // - @username + // - https://example.org/username + && preg_match("#(^|/|@)".preg_quote($value)."$#", trim(mb_strtolower($social_profile['value']))) + ) { + $ok = true; + break; + } + } + if (!$ok) { + $customers->forget($i); + } + } + + if ($exclude_channel && count($customers)) { + $exclude_customer_ids = CustomerChannel::whereIn('customer_id', $customers->pluck('id')) + ->where('channel', $exclude_channel) + ->pluck('customer_id'); + return $customers->whereNotIn('customer_id', $exclude_customer_ids); + } else { + return $customers; + } + } +} + diff --git a/freescout-dist/app/CustomerChannel.php b/freescout-dist/app/CustomerChannel.php new file mode 100644 index 0000000..ce1fbfc --- /dev/null +++ b/freescout-dist/app/CustomerChannel.php @@ -0,0 +1,58 @@ +belongsTo('App\Customer'); + } + + public static function create($customer_id, $channel, $channel_id) + { + try { + $customer_channel = new self(); + $customer_channel->customer_id = $customer_id; + $customer_channel->channel = $channel; + $customer_channel->channel_id = $channel_id; + $customer_channel->save(); + + return $customer_channel; + } catch (\Exception $e) { + // Already exists. + return null; + } + } + + public function getChannelName() + { + return \Eventy::filter('channel.name', '', $this->channel); + } + + public static function getChannels() + { + if (self::$channels !== null) { + return self::$channels; + } else { + self::$channels = \Eventy::filter('channels.list', []); + return self::$channels; + } + } +} diff --git a/freescout-dist/app/Email.php b/freescout-dist/app/Email.php new file mode 100644 index 0000000..4e2ed60 --- /dev/null +++ b/freescout-dist/app/Email.php @@ -0,0 +1,87 @@ + 'work', + self::TYPE_HOME => 'home', + self::TYPE_OTHER => 'other', + ]; + + public $timestamps = false; + + /** + * Attributes which are not fillable using fill() method. + */ + protected $guarded = ['id', 'customer_id']; + + /** + * Get email's customer. + */ + public function customer() + { + return $this->belongsTo('App\Customer'); + } + + /** + * Sanitize email address. + * + * @param string $email + * + * @return string + */ + public static function sanitizeEmail($email) + { + // FILTER_VALIDATE_EMAIL does not work with long emails for example + // Email validation is not recommended: + // http://stackoverflow.com/questions/201323/using-a-regular-expression-to-validate-an-email-address/201378#201378 + // So we just check for @ + if (!preg_match('/^.+@.+$/', $email ?? '')) { + return false; + } + $email = filter_var($email, FILTER_SANITIZE_EMAIL); + $email = mb_strtolower($email, 'UTF-8'); + // Remove trailing dots. + $email = preg_replace("/\.+$/", '', $email); + // Remove dot before @ + $email = preg_replace("/\.+@/", '@', $email); + + return $email; + } + + public function getNameFromEmail() + { + return explode('@', $this->email)[0]; + } + + public static function create($email, $customer_id, $type = self::TYPE_WORK) + { + try { + $email_obj = new Email(); + $email_obj->email = $email; + $email_obj->type = array_key_exists($type, self::$types) ? $type : self::TYPE_WORK; + $email_obj->customer_id = $customer_id; + $email_obj->save(); + + return $email_obj; + } catch (\Exception $e) { + return null; + } + } +} diff --git a/freescout-dist/app/Events/ConversationCustomerChanged.php b/freescout-dist/app/Events/ConversationCustomerChanged.php new file mode 100644 index 0000000..73cc854 --- /dev/null +++ b/freescout-dist/app/Events/ConversationCustomerChanged.php @@ -0,0 +1,28 @@ +conversation = $conversation; + $this->prev_customer_id = $prev_customer_id; + $this->prev_customer_email = $prev_customer_email; + $this->by_user = $by_user; + $this->by_customer = $by_customer; + } +} diff --git a/freescout-dist/app/Events/ConversationStatusChanged.php b/freescout-dist/app/Events/ConversationStatusChanged.php new file mode 100644 index 0000000..a992c87 --- /dev/null +++ b/freescout-dist/app/Events/ConversationStatusChanged.php @@ -0,0 +1,20 @@ +conversation = $conversation; + } +} diff --git a/freescout-dist/app/Events/ConversationUserChanged.php b/freescout-dist/app/Events/ConversationUserChanged.php new file mode 100644 index 0000000..8b1c028 --- /dev/null +++ b/freescout-dist/app/Events/ConversationUserChanged.php @@ -0,0 +1,23 @@ +conversation = $conversation; + $this->user = $user; + } +} diff --git a/freescout-dist/app/Events/CustomerCreatedConversation.php b/freescout-dist/app/Events/CustomerCreatedConversation.php new file mode 100644 index 0000000..eb2ee36 --- /dev/null +++ b/freescout-dist/app/Events/CustomerCreatedConversation.php @@ -0,0 +1,23 @@ +conversation = $conversation; + $this->last_thread = $last_thread; + } +} diff --git a/freescout-dist/app/Events/CustomerReplied.php b/freescout-dist/app/Events/CustomerReplied.php new file mode 100644 index 0000000..035bae8 --- /dev/null +++ b/freescout-dist/app/Events/CustomerReplied.php @@ -0,0 +1,23 @@ +conversation = $conversation; + $this->thread = $thread; + } +} diff --git a/freescout-dist/app/Events/RealtimeBroadcastNotificationCreated.php b/freescout-dist/app/Events/RealtimeBroadcastNotificationCreated.php new file mode 100644 index 0000000..e5443ad --- /dev/null +++ b/freescout-dist/app/Events/RealtimeBroadcastNotificationCreated.php @@ -0,0 +1,96 @@ +data = $data; + $this->notifiable = $notifiable; + $this->notification = $notification; + } + + /** + * Get the channels the event should broadcast on. + * + * @return Channel|array + */ + public function broadcastOn() + { + $channels = $this->notification->broadcastOn(); + + if (!empty($channels)) { + return $channels; + } + + return [new PrivateChannel($this->channelName())]; + + //return new PrivateChannel('App.User.'.$this->receiver_user_id); + } + + /** + * Get the data to broadcast. + * + * @return array + */ + public function broadcastWith() + { + return array_merge($this->data, [ + 'id' => $this->notification->id, + 'type' => get_class($this->notification), + ]); + // return [ + // 'thread_id' => $this->thread->id + // ]; + } + + /** + * Get the broadcast channel name for the event. + * + * @return string + */ + protected function channelName() + { + if (method_exists($this->notifiable, 'receivesBroadcastNotificationsOn')) { + return $this->notifiable->receivesBroadcastNotificationsOn($this->notification); + } + + $class = str_replace('\\', '.', get_class($this->notifiable)); + + return $class.'.'.$this->notifiable->getKey(); + } +} diff --git a/freescout-dist/app/Events/RealtimeChat.php b/freescout-dist/app/Events/RealtimeChat.php new file mode 100644 index 0000000..1d53ffd --- /dev/null +++ b/freescout-dist/app/Events/RealtimeChat.php @@ -0,0 +1,102 @@ +data = $data; + } + + /** + * Get the channels the event should broadcast on. + * + * @return Channel|array + */ + public function broadcastOn() + { + return new \Illuminate\Broadcasting\Channel($this->channelName()); + } + + /** + * Get the data to broadcast. + * + * @return array + */ + public function broadcastWith() + { + return $this->data; + } + + /** + * Get the broadcast channel name for the event. + * + * @return string + */ + protected function channelName() + { + if (!empty($this->data['mailbox_id'])) { + return 'chat.'.$this->data['mailbox_id']; + } else { + return 'chat.0'; + } + } + + /** + * Helper funciton. + */ + public static function dispatchSelf($mailbox_id) + { + if (!\Helper::isChatModeAvailable()) { + return; + } + $notification_data = [ + 'mailbox_id' => $mailbox_id + ]; + event(new \App\Events\RealtimeChat($notification_data)); + } + + public static function processPayload($payload) + { + $user = auth()->user(); + $mailbox = Mailbox::rememberForever()->find($payload->mailbox_id); + + // Check if user can listen to this event. + if (!$user || !$mailbox || !$user->can('viewCached', $mailbox)) { + return []; + } + + // Chats are retrieved in the template. + $template_data = [ + 'mailbox' => $mailbox, + ]; + + $payload->chats_html = \View::make('mailboxes/partials/chat_list')->with($template_data)->render(); + + return $payload; + } +} diff --git a/freescout-dist/app/Events/RealtimeConvNewThread.php b/freescout-dist/app/Events/RealtimeConvNewThread.php new file mode 100644 index 0000000..2fc71e8 --- /dev/null +++ b/freescout-dist/app/Events/RealtimeConvNewThread.php @@ -0,0 +1,118 @@ +data = $data; + } + + /** + * Get the channels the event should broadcast on. + * + * @return Channel|array + */ + public function broadcastOn() + { + return new \Illuminate\Broadcasting\Channel($this->channelName()); + } + + /** + * Get the data to broadcast. + * + * @return array + */ + public function broadcastWith() + { + return $this->data; + } + + /** + * Get the broadcast channel name for the event. + * + * @return string + */ + protected function channelName() + { + if (!empty($this->data['conversation_id'])) { + return 'conv.'.$this->data['conversation_id']; + } else { + return 'conv.0'; + } + } + + /** + * Helper funciton. + */ + public static function dispatchSelf($thread) + { + if ($thread->state != Thread::STATE_PUBLISHED) { + return; + } + $notification_data = [ + 'thread_id' => $thread->id, + 'conversation_id' => $thread->conversation_id, + // conversation is prefetched in ThreadObserver. + 'mailbox_id' => $thread->conversation->mailbox_id, + //'user_id' => $thread->created_by_user_id, + ]; + event(new \App\Events\RealtimeConvNewThread($notification_data)); + } + + public static function processPayload($payload) + { + $user = auth()->user(); + $mailbox = Mailbox::rememberForever()->find($payload->mailbox_id); + + // Check if user can listen to this event. + if (!$user || !$mailbox || !$user->can('viewCached', $mailbox)) { + return []; + } + + $thread = Thread::find($payload->thread_id); + if (!$thread) { + return $payload; + } + + // Add thread html to the payload. + $template_data = [ + 'conversation' => $thread->conversation, + 'mailbox' => $thread->conversation->mailbox, + 'threads' => [$thread], + ]; + + $payload->thread_html = \View::make('conversations/partials/threads')->with($template_data)->render(); + $payload->conversation_user_id = $thread->conversation->user_id; + $payload->conversation_status = $thread->conversation->status; + $payload->conversation_status_class = Conversation::$status_classes[$thread->conversation->status]; + $payload->conversation_status_icon = Conversation::$status_icons[$thread->conversation->status]; + + return $payload; + } +} diff --git a/freescout-dist/app/Events/RealtimeConvView.php b/freescout-dist/app/Events/RealtimeConvView.php new file mode 100644 index 0000000..05b92de --- /dev/null +++ b/freescout-dist/app/Events/RealtimeConvView.php @@ -0,0 +1,113 @@ +data = $data; + // $this->notifiable = $notifiable; + // $this->notification = $notification; + } + + /** + * Get the channels the event should broadcast on. + * + * @return Channel|array + */ + public function broadcastOn() + { + // $channels = $this->notification->broadcastOn(); + + // if (!empty($channels)) { + // return $channels; + // } + + return new \Illuminate\Broadcasting\Channel($this->channelName()); + //return [new PrivateChannel()]; + } + + /** + * Get the data to broadcast. + * + * @return array + */ + public function broadcastWith() + { + return $this->data; + + /*return array_merge($this->data, [ + 'id' => $this->notification->id, + 'type' => get_class($this->notification), + ]);*/ + } + + /** + * Get the broadcast channel name for the event. + * + * @return string + */ + protected function channelName() + { + // if (method_exists($this->notifiable, 'receivesBroadcastNotificationsOn')) { + // return $this->notifiable->receivesBroadcastNotificationsOn($this->notification); + // } + + return 'conv'; + + // $class = str_replace('\\', '.', get_class($this->notifiable)); + + // return $class.'.'.$this->notifiable->getKey(); + } + + /** + * Helper funciton. + */ + public static function dispatchSelf($conversation_id, $user, $replying = false) + { + $notification_data = [ + 'conversation_id' => $conversation_id, + 'user_id' => $user->id, + 'user_photo_url' => $user->getPhotoUrl(false), + // These has to be encoded to avoid "Unable to JSON encode payload. Error code: 5" + 'user_initials' => htmlentities($user->getInitials()), + 'user_name' => htmlentities($user->getFullName()), + 'replying' => (int)$replying, + ]; + event(new \App\Events\RealtimeConvView($notification_data)); + } +} diff --git a/freescout-dist/app/Events/RealtimeConvViewFinish.php b/freescout-dist/app/Events/RealtimeConvViewFinish.php new file mode 100644 index 0000000..a791b46 --- /dev/null +++ b/freescout-dist/app/Events/RealtimeConvViewFinish.php @@ -0,0 +1,62 @@ +data = $data; + } + + /** + * Get the channels the event should broadcast on. + * + * @return Channel|array + */ + public function broadcastOn() + { + return new \Illuminate\Broadcasting\Channel($this->channelName()); + } + + /** + * Get the data to broadcast. + * + * @return array + */ + public function broadcastWith() + { + return $this->data; + } + + /** + * Get the broadcast channel name for the event. + * + * @return string + */ + protected function channelName() + { + return 'conv'; + } +} diff --git a/freescout-dist/app/Events/RealtimeMailboxNewThread.php b/freescout-dist/app/Events/RealtimeMailboxNewThread.php new file mode 100644 index 0000000..72cd2b9 --- /dev/null +++ b/freescout-dist/app/Events/RealtimeMailboxNewThread.php @@ -0,0 +1,110 @@ +data = $data; + } + + /** + * Get the channels the event should broadcast on. + * + * @return Channel|array + */ + public function broadcastOn() + { + return new \Illuminate\Broadcasting\Channel($this->channelName()); + } + + /** + * Get the data to broadcast. + * + * @return array + */ + public function broadcastWith() + { + return $this->data; + } + + /** + * Get the broadcast channel name for the event. + * + * @return string + */ + protected function channelName() + { + if (!empty($this->data['mailbox_id'])) { + return 'mailbox.'.$this->data['mailbox_id']; + } else { + return 'mailbox.0'; + } + } + + /** + * Helper funciton. + */ + public static function dispatchSelf($mailbox_id) + { + $notification_data = [ + 'mailbox_id' => $mailbox_id + ]; + event(new \App\Events\RealtimeMailboxNewThread($notification_data)); + } + + public static function processPayload($payload) + { + $user = auth()->user(); + $mailbox = Mailbox::rememberForever()->find($payload->mailbox_id); + + // Check if user can listen to this event. + if (!$user || !$mailbox || !$user->can('viewCached', $mailbox)) { + return []; + } + + $folder = null; + $foler_id = Conversation::getFolderParam(); + if ($foler_id) { + $folder = Folder::find($foler_id); + } + // Just in case. + if (!$folder) { + $folder = new Folder(); + } + $template_data = [ + 'folders' => $mailbox->getAssesibleFolders(), + 'folder' => $folder, + 'mailbox' => $mailbox, + ]; + + $payload->folders_html = \View::make('mailboxes/partials/folders')->with($template_data)->render(); + + return $payload; + } +} diff --git a/freescout-dist/app/Events/UserAddedNote.php b/freescout-dist/app/Events/UserAddedNote.php new file mode 100644 index 0000000..888f5d7 --- /dev/null +++ b/freescout-dist/app/Events/UserAddedNote.php @@ -0,0 +1,23 @@ +conversation = $conversation; + $this->thread = $thread; + } +} diff --git a/freescout-dist/app/Events/UserCreatedConversation.php b/freescout-dist/app/Events/UserCreatedConversation.php new file mode 100644 index 0000000..6aed202 --- /dev/null +++ b/freescout-dist/app/Events/UserCreatedConversation.php @@ -0,0 +1,23 @@ +conversation = $conversation; + $this->last_thread = $last_thread; + } +} diff --git a/freescout-dist/app/Events/UserCreatedConversationDraft.php b/freescout-dist/app/Events/UserCreatedConversationDraft.php new file mode 100644 index 0000000..720a689 --- /dev/null +++ b/freescout-dist/app/Events/UserCreatedConversationDraft.php @@ -0,0 +1,23 @@ +conversation = $conversation; + $this->thread = $thread; + } +} diff --git a/freescout-dist/app/Events/UserCreatedThreadDraft.php b/freescout-dist/app/Events/UserCreatedThreadDraft.php new file mode 100644 index 0000000..6a32655 --- /dev/null +++ b/freescout-dist/app/Events/UserCreatedThreadDraft.php @@ -0,0 +1,23 @@ +conversation = $conversation; + $this->thread = $thread; + } +} diff --git a/freescout-dist/app/Events/UserDeleted.php b/freescout-dist/app/Events/UserDeleted.php new file mode 100644 index 0000000..d060191 --- /dev/null +++ b/freescout-dist/app/Events/UserDeleted.php @@ -0,0 +1,24 @@ +by_user = $by_user; + $this->deleted_user = $deleted_user; + + \Eventy::action('user.deleted', $deleted_user, $by_user); + } +} diff --git a/freescout-dist/app/Events/UserReplied.php b/freescout-dist/app/Events/UserReplied.php new file mode 100644 index 0000000..447fabe --- /dev/null +++ b/freescout-dist/app/Events/UserReplied.php @@ -0,0 +1,23 @@ +conversation = $conversation; + $this->thread = $thread; + } +} diff --git a/freescout-dist/app/Exceptions/Handler.php b/freescout-dist/app/Exceptions/Handler.php new file mode 100644 index 0000000..7d3f5c7 --- /dev/null +++ b/freescout-dist/app/Exceptions/Handler.php @@ -0,0 +1,55 @@ +payload_decoded !== null) { + return $this->payload_decoded; + } + + $this->payload_decoded = json_decode($this->payload, true); + + return $this->payload_decoded; + } + + public function getCommand() + { + return \App\Job::getPayloadCommand($this->getPayloadDecoded()); + } + + public static function retry($job_id) + { + \Artisan::call('queue:retry', ['id' => $job_id]); + } +} diff --git a/freescout-dist/app/Folder.php b/freescout-dist/app/Folder.php new file mode 100644 index 0000000..0095293 --- /dev/null +++ b/freescout-dist/app/Folder.php @@ -0,0 +1,456 @@ + 'Unassigned', + self::TYPE_MINE => 'Mine', + self::TYPE_STARRED => 'Starred', + self::TYPE_DRAFTS => 'Drafts', + self::TYPE_ASSIGNED => 'Assigned', + self::TYPE_CLOSED => 'Closed', + self::TYPE_SPAM => 'Spam', + self::TYPE_DELETED => 'Deleted', + ]; + + /** + * https://glyphicons.bootstrapcheatsheets.com/. + */ + public static $type_icons = [ + self::TYPE_UNASSIGNED => 'folder-open', + self::TYPE_MINE => 'hand-right', + self::TYPE_DRAFTS => 'duplicate', + self::TYPE_ASSIGNED => 'user', + self::TYPE_CLOSED => 'lock', // lock + self::TYPE_SPAM => 'ban-circle', + self::TYPE_DELETED => 'trash', + self::TYPE_STARRED => 'star', + ]; + + // Public non-user specific mailbox types + public static $public_types = [ + self::TYPE_UNASSIGNED, + self::TYPE_DRAFTS, + self::TYPE_ASSIGNED, + self::TYPE_CLOSED, + self::TYPE_SPAM, + self::TYPE_DELETED, + ]; + + // Folder types which belong to specific user. + // These folders has user_id specified. + public static $personal_types = [ + self::TYPE_MINE, + self::TYPE_STARRED, + ]; + + // Folder types to which conversations are added via conversation_folder table. + public static $indirect_types = [ + self::TYPE_DRAFTS, + self::TYPE_STARRED, + ]; + + // Counter mode. + const COUNTER_ACTIVE = 1; + const COUNTER_TOTAL = 2; + + public $timestamps = false; + + protected $casts = [ + 'meta' => 'array', + ]; + + /** + * Get the mailbox to which folder belongs. + */ + public function mailbox() + { + return $this->belongsTo('App\Mailbox'); + } + + /** + * Get the user to which folder belongs. + */ + public function user() + { + return $this->belongsTo('App\User'); + } + + /** + * Get starred conversations. + */ + public function conversations() + { + return $this->hasMany('App\Conversation'); + } + + public function getTypeName() + { + // To make name translatable. + switch ($this->type) { + case self::TYPE_UNASSIGNED: + return __('Unassigned'); + case self::TYPE_MINE: + return __('Mine'); + case self::TYPE_DRAFTS: + return __('Drafts'); + case self::TYPE_ASSIGNED: + return __('Assigned'); + case self::TYPE_CLOSED: + return __('Closed'); + case self::TYPE_SPAM: + return __('Spam'); + case self::TYPE_DELETED: + return __('Deleted'); + case self::TYPE_STARRED: + return __('Starred'); + default: + return __(\Eventy::filter('folder.type_name', self::$types[$this->type] ?? '', $this)); + } + } + + public function getTypeIcon() + { + return \Eventy::filter('folder.type_icon', self::$type_icons[$this->type] ?? '', $this); + } + + /** + * Get order by array. + * + * @return array + */ + public function getOrderByArray() + { + $order_by = []; + + switch ($this->type) { + case self::TYPE_UNASSIGNED: + case self::TYPE_MINE: + case self::TYPE_ASSIGNED: + $order_by[] = ['status' => 'asc']; + $order_by[] = ['last_reply_at' => 'desc']; + break; + + case self::TYPE_STARRED: + $order_by[] = ['status' => 'asc']; + $order_by[] = ['last_reply_at' => 'desc']; + break; + + case self::TYPE_DRAFTS: + $order_by = [['updated_at' => 'desc']]; + break; + + case self::TYPE_CLOSED: + $order_by = [['closed_at' => 'desc']]; + break; + + case self::TYPE_SPAM: + $order_by = [['last_reply_at' => 'desc']]; + break; + + case self::TYPE_DELETED: + $order_by = [['user_updated_at' => 'desc']]; + break; + + default: + $order_by = \Eventy::filter('folder.conversations_order_by', $order_by, $this->type); + break; + } + + // Process columns sorting. + $sorting = Conversation::getConvTableSorting(); + if ($sorting['sort_by'] == 'date') { + if ($sorting['order'] != 'desc') { + foreach ($order_by as $block_i => $block) { + foreach ($block as $field => $order) { + if ($field == 'status') { + unset($order_by[$block_i][$field]); + } else { + if ($order == 'desc') { + $order_by[$block_i][$field] = 'asc'; + } + } + } + } + } + } else { + $order_by = [[$sorting['sort_by'] => $sorting['order']]]; + } + + return $order_by; + } + + /** + * Add order by to the query. + */ + public function queryAddOrderBy($query) + { + $order_bys = $this->getOrderByArray(); + foreach ($order_bys as $order_by) { + foreach ($order_by as $field => $sort_order) { + $query->orderBy($field, $sort_order); + } + } + + return $query; + } + + /** + * Is this folder accumulates conversations via conversation_folder table. + */ + public function isIndirect() + { + return in_array($this->type, self::$indirect_types); + } + + public function updateCounters() + { + if (config('app.update_folder_counters_in_background')) { + \App\Jobs\UpdateFolderCounters::dispatch($this); + } else { + $this->updateCountersNow(); + } + } + + public function updateCountersNow() + { + if (\Eventy::filter('folder.update_counters', false, $this)) { + return; + } + if ($this->type == self::TYPE_MINE && $this->user_id) { + $this->active_count = Conversation::where('user_id', $this->user_id) + ->where('mailbox_id', $this->mailbox_id) + ->where('state', Conversation::STATE_PUBLISHED) + ->where('status', Conversation::STATUS_ACTIVE) + ->count(); + $this->total_count = Conversation::where('user_id', $this->user_id) + ->where('mailbox_id', $this->mailbox_id) + ->where('state', Conversation::STATE_PUBLISHED) + ->count(); + } elseif ($this->type == self::TYPE_STARRED) { + $this->active_count = count(Conversation::getUserStarredConversationIds($this->mailbox_id, $this->user_id)); + $this->total_count = $this->active_count; + } elseif ($this->type == self::TYPE_DELETED) { + $this->active_count = $this->conversations()->where('state', Conversation::STATE_DELETED) + ->count(); + $this->total_count = $this->active_count; + } elseif ($this->isIndirect()) { + // Conversation are connected to folder via conversation_folder table. + // Drafts. + $this->active_count = ConversationFolder::where('conversation_folder.folder_id', $this->id) + ->join('conversations', 'conversations.id', '=', 'conversation_folder.conversation_id') + //->where('state', Conversation::STATE_PUBLISHED) + ->count(); + $this->total_count = $this->active_count; + } else { + $this->active_count = $this->conversations() + ->where('state', Conversation::STATE_PUBLISHED) + ->where('status', Conversation::STATUS_ACTIVE) + ->count(); + $this->total_count = $this->conversations() + ->where('state', Conversation::STATE_PUBLISHED) + ->count(); + } + $this->save(); + } + + /** + * Get count to display in folders list. + * + * @param array $folders [description] + * + * @return [type] [description] + */ + public function getCount($folders = []) + { + $counter = \Eventy::filter('folder.counter', self::COUNTER_ACTIVE, $this, $folders); + + $count = \Eventy::filter('folder.count', false, $this, $counter, $folders); + if ($count !== false) { + return $count; + } + + if ($counter == self::COUNTER_TOTAL || $this->type == self::TYPE_STARRED || $this->type == self::TYPE_DRAFTS) { + return $this->total_count; + } else { + return $this->getActiveCount($folders); + } + } + + /** + * Get calculated number of active conversations. + */ + public function getActiveCount($folders = []) + { + $active_count = $this->active_count; + if ($this->type == self::TYPE_ASSIGNED) { + $mine_folder = \Eventy::filter('folder.active_count_mine_folder', null, $this, $folders); + + if (!$mine_folder) { + if ($folders) { + $mine_folder = $folders->firstWhere('type', self::TYPE_MINE); + } elseif ($this->mailbox_id) { + $mine_folder = $this->mailbox->folders()->where('type', self::TYPE_MINE)->first(); + } + } + + if ($mine_folder) { + $active_count = $active_count - $mine_folder->active_count; + if ($active_count < 0) { + $active_count = 0; + } + } + } + + return $active_count; + } + + /** + * Query for waiting since. + */ + public function getWaitingSinceQuery() + { + $query = null; + + if ($this->type == self::TYPE_MINE) { + // Assigned to user. + $query = Conversation::where('user_id', $this->user_id) + ->where('mailbox_id', $this->mailbox_id); + } elseif ($this->isIndirect()) { + // Via intermediate table. + $query = Conversation::join('conversation_folder', 'conversations.id', '=', 'conversation_folder.conversation_id') + ->where('conversation_folder.folder_id', $this->id); + } else { + // All other conversations. + $query = $this->conversations(); + } + + return \Eventy::filter('folder.waiting_since_query', $query, $this); + } + + /** + * Works for main folder only for now. + * + * @return [type] [description] + */ + public function getWaitingSince() + { + // Get oldest active conversation. + $conversation = $this->getWaitingSinceQuery() + ->where('state', Conversation::STATE_PUBLISHED) + ->where('status', Conversation::STATUS_ACTIVE) + ->orderBy($this->getWaitingSinceField(), 'asc') + ->first(); + if ($conversation) { + return $conversation->getWaitingSince($this); + } else { + return ''; + } + } + + /** + * Get conversation field used to detect waiting since time. + * + * @return [type] [description] + */ + public function getWaitingSinceField() + { + if ($this->type == \App\Folder::TYPE_CLOSED) { + return 'closed_at'; + } elseif ($this->type == \App\Folder::TYPE_DRAFTS) { + return 'updated_at'; + } elseif ($this->type == \App\Folder::TYPE_DELETED) { + return 'user_updated_at'; + } else { + return'last_reply_at'; + } + } + + public function url($mailbox_id) + { + return \Eventy::filter('folder.url', route('mailboxes.view.folder', ['id'=>$mailbox_id, 'folder_id'=>$this->id]), $mailbox_id, $this); + } + + public static function create($data, $unique_per_user = true, $save = true) + { + if (!isset($data['mailbox_id']) || !isset($data['type'])) { + return null; + } + $folder = new Folder (); + $folder->mailbox_id = $data['mailbox_id']; + $folder->type = $data['type']; + + if (!empty($data['user_id'])) { + if ($unique_per_user) { + $user_folder = Folder::where('mailbox_id', $data['mailbox_id']) + ->where('user_id', $data['user_id']) + ->where('type', $data['type']) + ->first(); + // User folder already exists. + if ($user_folder) { + return $user_folder; + } + } + $folder->user_id = $data['user_id']; + } + + if ($save) { + $folder->save(); + } + + return $folder; + } + + /** + * Get meta value. + */ + public function getMeta($key, $default = null) + { + $metas = $this->meta; + if (isset($metas[$key])) { + return $metas[$key]; + } else { + return $default; + } + } + + /** + * Set meta value. + */ + public function setMeta($key, $value) + { + $metas = $this->meta; + $metas[$key] = $value; + $this->meta = $metas; + } + + /** + * Unset thread meta value. + */ + public function unsetMeta($key) + { + $metas = $this->meta; + if (isset($metas[$key])) { + unset($metas[$key]); + $this->meta = $metas; + } + } +} diff --git a/freescout-dist/app/Follower.php b/freescout-dist/app/Follower.php new file mode 100644 index 0000000..0df7b9e --- /dev/null +++ b/freescout-dist/app/Follower.php @@ -0,0 +1,10 @@ +middleware('guest'); + } +} diff --git a/freescout-dist/app/Http/Controllers/Auth/LoginController.php b/freescout-dist/app/Http/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..1a63bd4 --- /dev/null +++ b/freescout-dist/app/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,79 @@ +middleware('guest')->except('logout'); + } + + /** + * Handle a login request to the application. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse + */ + public function login(Request $request) + { + $this->validateLogin($request); + + // If the class is using the ThrottlesLogins trait, we can automatically throttle + // the login attempts for this application. We'll key this by the username and + // the IP address of the client making these requests into this application. + if ($this->hasTooManyLoginAttempts($request)) { + $this->fireLockoutEvent($request); + + return $this->sendLockoutResponse($request); + } + + $custom_errors = \Eventy::filter('login.custom_check', [], $request); + if ($custom_errors) { + throw ValidationException::withMessages($custom_errors); + } + + if ($this->attemptLogin($request)) { + return $this->sendLoginResponse($request); + } + + // If the login attempt was unsuccessful we will increment the number of attempts + // to login and redirect the user back to the login form. Of course, when this + // user surpasses their maximum number of attempts they will get locked out. + $this->incrementLoginAttempts($request); + + return $this->sendFailedLoginResponse($request); + } +} diff --git a/freescout-dist/app/Http/Controllers/Auth/RegisterController.php b/freescout-dist/app/Http/Controllers/Auth/RegisterController.php new file mode 100644 index 0000000..395a007 --- /dev/null +++ b/freescout-dist/app/Http/Controllers/Auth/RegisterController.php @@ -0,0 +1,87 @@ +middleware('guest'); + } + + /** + * Get a validator for an incoming registration request. + * + * @param array $data + * + * @return \Illuminate\Contracts\Validation\Validator + */ + protected function validator(array $data) + { + return Validator::make($data, [ + 'first_name' => 'required|string|max:255', + 'last_name' => 'required|string|max:255', + 'email' => 'required|string|email|max:191|unique:users', + 'password' => 'required|string|min:8|confirmed', + ]); + } + + /** + * Create a new user instance after a valid registration. + * + * @param array $data + * + * @return \App\User + */ + protected function create(array $data) + { + return User::create([ + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'email' => $data['email'], + 'password' => bcrypt($data['password']), + ]); + } + + /** + * Registration is disabled. + */ + public function showRegistrationForm() + { + return redirect('login'); + } + + public function register() + { + } +} diff --git a/freescout-dist/app/Http/Controllers/Auth/ResetPasswordController.php b/freescout-dist/app/Http/Controllers/Auth/ResetPasswordController.php new file mode 100644 index 0000000..c730eec --- /dev/null +++ b/freescout-dist/app/Http/Controllers/Auth/ResetPasswordController.php @@ -0,0 +1,62 @@ +middleware('guest'); + } + + /** + * Reset the given user's password. + * + * @param \Illuminate\Contracts\Auth\CanResetPassword $user + * @param string $password + * @return void + */ + protected function resetPassword($user, $password) + { + $user->password = Hash::make($password); + + $user->setRememberToken(Str::random(60)); + + $user->save(); + + event(new PasswordReset($user)); + + //$this->guard()->login($user); + } +} diff --git a/freescout-dist/app/Http/Controllers/Controller.php b/freescout-dist/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..a0a2a8a --- /dev/null +++ b/freescout-dist/app/Http/Controllers/Controller.php @@ -0,0 +1,13 @@ +middleware('auth'); + } + + /** + * View conversation. + */ + public function view(Request $request, $id) + { + $conversation = Conversation::findOrFail($id); + $this->authorize('viewCached', $conversation); + + $mailbox = $conversation->mailbox; + $customer = $conversation->customer_cached; + $user = auth()->user(); + + // To let other parts of the app easily access. + \Helper::setGlobalEntity('conversation', $conversation); + \Helper::setGlobalEntity('mailbox', $mailbox); + + if ($user->isAdmin()) { + $mailbox->fetchUserSettings($user->id); + } + + // Mark notifications as read + if (!empty($request->mark_as_read)) { + $mark_read_result = $user->unreadNotifications()->where('id', $request->mark_as_read)->update(['read_at' => now()]); + $user->clearWebsiteNotificationsCache(); + } else { + $mark_read_result = $user->unreadNotifications()->where('data', 'like', '%"conversation_id":'.$conversation->id.'%')->update(['read_at' => now()]); + } + if ($mark_read_result) { + $user->clearWebsiteNotificationsCache(); + } + + // Detect folder and redirect if needed + $folder = null; + if (Conversation::getFolderParam()) { + $folder = $conversation->mailbox->folders()->where('folders.id', Conversation::getFolderParam())->first(); + + // Pass some params when redirecting. + $params = []; + if (!empty($request->show_draft)) { + $params['show_draft'] = $request->show_draft; + } + + if ($folder) { + // Check if conversation can be located in the passed folder_id + if (!$conversation->isInFolderAllowed($folder)) { + + // Without reflash green flash will not be displayed on assignee change + \Session::reflash(); + //$request->session()->reflash(); + return redirect()->away($conversation->url($conversation->folder_id, null, $params)); + } + // If conversation assigned to user, select Mine folder instead of Assigned + if ($folder->type == Folder::TYPE_ASSIGNED && $conversation->user_id == $user->id) { + $folder = $conversation->mailbox->folders() + ->where('type', Folder::TYPE_MINE) + ->where('user_id', $user->id) + ->first(); + + \Session::reflash(); + + return redirect()->away($conversation->url($folder->id, null, $params)); + } + } + } + + // Add folder if empty + if (!$folder) { + if ($conversation->user_id == $user->id) { + $folder = $conversation->mailbox->folders() + ->where('type', Folder::TYPE_MINE) + ->where('user_id', $user->id) + ->first(); + } else { + $folder = $conversation->folder; + } + + \Session::reflash(); + + return redirect()->away($conversation->url($folder->id)); + } + + //$after_send = $conversation->mailbox->getUserSettings($user->id)->after_send; + $after_send = $user->mailboxSettings($conversation->mailbox_id)->after_send; + + // Detect customers and emails to which user can reply + $to_customers = []; + // Add all customer emails + $customer_emails = []; + $distinct_emails = []; + + // Add emails of customers from whom there were replies in the conversation + $prev_customers_emails = []; + if ($conversation->customer_email) { + $prev_customers_emails = Thread::select('from', 'customer_id') + ->where('conversation_id', $id) + ->where('type', Thread::TYPE_CUSTOMER) + ->where('from', '<>', $conversation->customer_email) + ->groupBy(['from', 'customer_id']) + ->get(); + } + + foreach ($prev_customers_emails as $prev_customer) { + if (!in_array($prev_customer->from, $distinct_emails) && $prev_customer->customer && $prev_customer->from) { + $to_customers[] = [ + 'customer' => $prev_customer->customer, + 'email' => $prev_customer->from, + ]; + $distinct_emails[] = $prev_customer->from; + } + } + + // Add customer email(s) if there more than one or if there are other emails in threads. + if ($customer) { + $customer_emails = $customer->emails; + } + // This is tricky case - when customer_email is different from the + // currently selected customer. + // 1. Email has been received from a customer. + // 2. Customer has been changed. + // 3. Reply has been sent to the original customer email. + if ($conversation->customer_email + && count($customer_emails) + && !in_array($conversation->customer_email, $customer_emails->pluck('email')->toArray()) + ) { + $extra_customer_added = false; + foreach ($to_customers as $to_customer) { + if ($to_customer['email'] == $conversation->customer_email) { + $extra_customer_added = true; + break; + } + } + if (!$extra_customer_added) { + // Get customer by email. + $extra_customer = Customer::getByEmail($conversation->customer_email); + if ($extra_customer) { + $to_customers[] = [ + 'customer' => $extra_customer, + 'email' => $conversation->customer_email, + ]; + } + } + } + if (count($customer_emails) > 1 || count($to_customers)) { + foreach ($customer_emails as $customer_email) { + $to_customers[] = [ + 'customer' => $customer, + 'email' => $customer_email->email, + ]; + $distinct_emails[] = $customer_email->email; + } + } + + // Exclude mailbox emails from $to_customers. + $mailbox_emails = $mailbox->getEmails(); + foreach ($to_customers as $key => $to_customer) { + if (in_array($to_customer['email'], $mailbox_emails)) { + unset($to_customers[$key]); + } + } + + $threads = $conversation->threads()->orderBy('created_at', 'desc')->get(); + + // Get To for new conversation. + $new_conv_to = []; + if (empty($threads[0]) || empty($threads[0]->to)) { + // Before new conversation To field was stored in $conversation->customer_email. + $emails = Conversation::sanitizeEmails($conversation->customer_email); + // Get customers info for emails. + if (count($emails)) { + $new_conv_to = Customer::emailsToCustomers($emails); + } + } else { + $new_conv_to = Customer::emailsToCustomers($threads[0]->getToArray()); + } + + if (empty($customer) && count($new_conv_to) == 1) { + $customer = Customer::getByEmail(array_key_first($new_conv_to)); + } + + // Previous conversations + $prev_conversations = []; + if ($customer) { + $prev_conversations = $mailbox->conversations() + ->where('customer_id', $customer->id) + ->where('id', '<>', $conversation->id) + ->where('status', '!=', Conversation::STATUS_SPAM) + ->where('state', Conversation::STATE_PUBLISHED) + //->limit(self::PREV_CONVERSATIONS_LIMIT) + ->orderBy('created_at', 'desc') + ->paginate(self::PREV_CONVERSATIONS_LIMIT); + } + + $template = 'conversations/view'; + if ($conversation->state == Conversation::STATE_DRAFT) { + $template = 'conversations/create'; + } + + // CC. + $exclude_array = $conversation->getExcludeArray($mailbox); + $cc = $conversation->getCcArray($exclude_array); + + // If last reply came from customer who was mentioned in CC before, + // we need to add this customer as CC. + // https://github.com/freescout-helpdesk/freescout/issues/3613 + foreach ($threads as $thread) { + if ($thread->isUserMessage() && !$thread->isDraft()) { + break; + } + if ($thread->isCustomerMessage()) { + if ($thread->customer_id != $conversation->customer_id) { + $cc[] = $thread->from; + } + break; + } + } + + // Get data for creating a phone conversation. + $name = []; + $phone = ''; + $to_email = []; + if ($customer) { + if ($customer->getFullName()) { + $name = [$customer->id => $customer->getFullName()]; + } + $last_phone = array_last($customer->getPhones()); + if (!empty($last_phone)) { + $phone = $last_phone['value']; + } + + if ($conversation->customer_email) { + $customer_email = $conversation->customer_email; + } else { + $customer_email = $customer->getMainEmail(); + } + if ($customer_email) { + $to_email = [$customer_email]; + } + } + + // Notify other users that current user is viewing conversation. + // Eventually notification data will be saved in polycast_events table and processes + // in JS in users browsers. + + // $notification = new \App\Notifications\UserViewingConversationNotification( + // $conversation, $user, false + // ); + + // This broadcasts to specific users. + // \Notification::send($mailbox->usersHavingAccess(), $notification); + + // Notification is sent to all via public channel: conview + // If we send notification to each user, applications having thouthans of users + // will be overloaded. + // // https://laravel.com/docs/5.5/broadcasting#broadcasting-events + \App\Events\RealtimeConvView::dispatchSelf($conversation->id, $user, false); + + // Get viewers. + $viewers = []; + $conv_view = \Cache::get('conv_view'); + if ($conv_view && !empty($conv_view[$conversation->id])) { + $viewing_users = User::whereIn('id', array_keys($conv_view[$conversation->id]))->get(); + foreach ($viewing_users as $viewer) { + if (isset($conv_view[$conversation->id][$viewer->id]['r']) && $viewer->id != $user->id) { + $viewers[] = [ + 'user' => $viewer, + 'replying' => (int)$conv_view[$conversation->id][$viewer->id]['r'] + ]; + } + } + // Show replying first. + usort($viewers, function($a, $b) { + return $b['replying'] <=> $a['replying']; + }); + } + + $is_following = $conversation->isUserFollowing($user->id); + + \Eventy::action('conversation.view.start', $conversation, $request); + + // Mailbox aliases. + $from_aliases = $conversation->mailbox->getAliases(true, true); + $from_alias = ''; + + if (count($from_aliases) == 1) { + $from_aliases = []; + } + if ($conversation->isDraft() && !empty($threads[0])) { + $from_alias = $threads[0]->from ?? ''; + } + if (count($from_aliases) && !$from_alias) { + // Preset the last alias used. + $check_initial_thread = true; + foreach ($threads as $thread) { + if ($thread->isUserMessage() && !$thread->isDraft()) { + $check_initial_thread = false; + if ($thread->from) { + $from_alias = $thread->from; + } + break; + } + } + // Maybe the first email has been sent to some mailbox alias. + if (!$from_alias && $check_initial_thread) { + $initial_thread = $threads->last(); + if ($initial_thread && $initial_thread->isCustomerMessage()) { + $initial_recipients = $initial_thread->getToArray(); + $initial_recipients = array_merge($initial_recipients, $initial_thread->getCcArray()); + foreach ($initial_recipients as $initial_recipient) { + foreach ($from_aliases as $from_alias_email => $dummy) { + if ($initial_recipient == $from_alias_email) { + $from_alias = $from_alias_email; + break 2; + } + } + } + } + } + } + + return view($template, [ + 'conversation' => $conversation, + 'mailbox' => $conversation->mailbox, + 'customer' => $customer, + 'threads' => \Eventy::filter('conversation.view.threads', $threads), + 'folder' => $folder, + 'folders' => $conversation->mailbox->getAssesibleFolders(), + 'after_send' => $after_send, + 'to' => $new_conv_to, + 'to_customers' => $to_customers, + 'prev_conversations' => $prev_conversations, + 'cc' => $cc, + 'bcc' => [], //$conversation->getBccArray($exclude_array), + // Data for creating a phone conversation. + 'name' => $name, + 'phone' => $phone, + 'to_email' => $to_email, + 'viewers' => $viewers, + 'is_following' => $is_following, + 'from_aliases' => $from_aliases, + 'from_alias' => $from_alias, + ]); + } + + /** + * New conversation. + */ + public function create(Request $request, $mailbox_id) + { + $mailbox = Mailbox::findOrFail($mailbox_id); + $this->authorize('view', $mailbox); + + $subject = trim($request->get('subject') ?? ''); + + $conversation = new Conversation(); + $conversation->body = ''; + $conversation->mailbox = $mailbox; + + $folder = $mailbox->folders()->where('type', Folder::TYPE_DRAFTS)->first(); + + // todo: use $user->mailboxSettings() + $after_send = $mailbox->getUserSettings(auth()->user()->id)->after_send; + + // Create conversation from thread + $thread = null; + if (!empty($request->from_thread_id)) { + $orig_thread = Thread::find($request->from_thread_id); + if ($orig_thread) { + $subject = $orig_thread->conversation->subject; + $subject = preg_replace('/^Fwd:/i', 'Re: ', $subject); + + $thread = new \App\Thread(); + $thread->body = $orig_thread->body; + // If this is a forwarded message, try to fetch From + preg_match_all("/From:[^<\n]+<([^<\n]+)>/m", html_entity_decode(strip_tags($thread->body)), $m); + + if (!empty($m[1])) { + foreach ($m[1] as $value) { + if (\MailHelper::validateEmail($value)) { + $thread->to = json_encode([$value]); + break; + } + } + } + } + } + + $to = []; + + // Prefill some values. + $prefill_to = \App\Email::sanitizeEmail($request->get('to')); + if ($prefill_to) { + $to = [$prefill_to => $prefill_to]; + } + $conversation->subject = $subject; + + return view('conversations/create', [ + 'conversation' => $conversation, + 'thread' => $thread, + 'mailbox' => $mailbox, + 'folder' => $folder, + 'folders' => $mailbox->getAssesibleFolders(), + 'after_send' => $after_send, + 'to' => $to, + 'from_aliases' => $mailbox->getAliases(true, true), + ]); + } + + /** + * Clone conversation. + */ + public function cloneConversation(Request $request, $mailbox_id, $from_thread_id) + { + $mailbox = Mailbox::findOrFail($mailbox_id); + $this->authorize('view', $mailbox); + + if (!empty($from_thread_id)) { + $orig_thread = Thread::find($from_thread_id); + + if ($orig_thread) { + $orign_conv = $orig_thread->conversation; + $this->authorize('view', $orign_conv); + + + // $thread = $orig_thread->replicate(); + // $thread->id = ''; + // $thread->message_id .= ".clone".crc32(mktime()); + // $thread->status = Thread::STATUS_ACTIVE; + // $thread->conversation_id = $conversation->id; + // $thread->save(); + + + $now = date('Y-m-d H:i:s'); + + $conversation = new Conversation(); + $conversation->type = $orign_conv->type; + $conversation->subject = $orign_conv->subject; + $conversation->mailbox_id = $orign_conv->mailbox_id; + $conversation->preview = ''; + // Preset source_via here to avoid error in PostgreSQL. + $conversation->source_via = $orign_conv->source_via; + $conversation->source_type = $orign_conv->source_type; + $conversation->customer_id = $orign_conv->customer_id; + $conversation->customer_email = $orign_conv->customer->getMainEmail(); + $conversation->status = Conversation::STATUS_ACTIVE; + $conversation->state = Conversation::STATE_PUBLISHED; + $conversation->cc = $orig_thread->cc; + $conversation->bcc = $orig_thread->bcc; + // Set assignee + $conversation->user_id = $orign_conv->user_id; + $conversation->updateFolder(); + $conversation->save(); + + + $thread = Thread::createExtended([ + 'conversation_id' => $orig_thread->conversation_id, + 'user_id' => $orig_thread->user_id, + 'type' => $orig_thread->type, + 'status' => $conversation->status, + 'state' => $conversation->state, + 'body' => $orig_thread->body, + 'headers' => $orig_thread->headers, + 'from' => $orig_thread->from, + 'to' => $orig_thread->to, + 'cc' => $orig_thread->cc, + 'bcc' => $orig_thread->bcc, + //'attachments' => $attachments, + 'has_attachments' => $orig_thread->has_attachments, + 'message_id' => "clone".crc32(microtime()).'-'.$orig_thread->message_id, + 'source_via' => $orig_thread->source_via, + 'source_type' => $orig_thread->source_type, + 'customer_id' => $orig_thread->customer_id, + 'created_by_customer_id' => $orig_thread->created_by_customer_id, + ], + $conversation + ); + + // Clone attachments. + $attachments = Attachment::where('thread_id', $orig_thread->id)->get(); + foreach ($attachments as $attachment) { + $attachment->duplicate($thread->id); + } + + return redirect()->away($conversation->url()); + } else { + return redirect()->away($mailbox->url()); + } + } else { + return redirect()->away($mailbox->url()); + } + } + + /** + * Conversation draft. + */ + // public function draft($id) + // { + // $conversation = Conversation::findOrFail($id); + + // $this->authorize('view', $conversation); + + // return view('conversations/create', [ + // 'conversation' => $conversation, + // 'mailbox' => $conversation->mailbox, + // 'folder' => $conversation->folder, + // 'folders' => $conversation->mailbox->getAssesibleFolders(), + // ]); + // } + + /** + * Conversations ajax controller. + */ + public function ajax(Request $request) + { + $response = [ + 'status' => 'error', + 'msg' => '', // this is error message + ]; + + $user = auth()->user(); + + switch ($request->action) { + + // Change conversation user + case 'conversation_change_user': + $conversation = Conversation::find($request->conversation_id); + + $new_user_id = (int) $request->user_id; + + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } + if (!$response['msg'] && $conversation->user_id == $new_user_id) { + $response['msg'] = __('Assignee already set'); + } + if (!$response['msg'] && !$user->can('update', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + if (!$response['msg'] && (int) $new_user_id != -1 && !$conversation->mailbox->userHasAccess($new_user_id)) { + $response['msg'] = __('Not enough permissions'); + } + if (!$response['msg']) { + // Determine redirect + // Must be done before updating current conversation's status or assignee. + $redirect_same_page = false; + if ($new_user_id == $user->id || $request->x_embed == 1) { + // If user assigned conversation to himself, stay on the current page + $response['redirect_url'] = $conversation->url(); + $redirect_same_page = true; + } else { + $response['redirect_url'] = $this->getRedirectUrl($request, $conversation, $user); + } + + $conversation->changeUser($new_user_id, $user); + + $response['status'] = 'success'; + + // Flash + $flash_message = __('Assignee updated'); + if (!$redirect_same_page || $response['redirect_url'] != $conversation->url()) { + $flash_message .= '  '.__('View').''; + } + \Session::flash('flash_success_floating', $flash_message); + + $response['msg'] = __('Assignee updated'); + } + break; + + // Change conversation status + case 'conversation_change_status': + $conversation = Conversation::find($request->conversation_id); + + if ($request->status == 'not_spam') { + // Find previous status in threads + $new_status = $conversation + ->threads() + ->orderBy('created_at', 'desc') + ->where('status', '!=', Thread::STATUS_SPAM) + ->where('type', Thread::TYPE_LINEITEM) + ->where('action_type', Thread::ACTION_TYPE_STATUS_CHANGED) + ->value('status'); + if (!$new_status) { + $new_status = Thread::STATUS_ACTIVE; + } + } else { + $new_status = (int) $request->status; + } + + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } + if (!$response['msg'] && $conversation->status == $new_status) { + $response['msg'] = __('Status already set'); + } + if (!$response['msg'] && !$user->can('update', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + if (!$response['msg'] && !in_array((int) $new_status, array_keys(Conversation::$statuses))) { + $response['msg'] = __('Incorrect status'); + } + if (!$response['msg']) { + // Determine redirect + // Must be done before updating current conversation's status or assignee. + $redirect_same_page = false; + if ($request->status == 'not_spam' || $request->x_embed == 1) { + // Stay on the current page + $response['redirect_url'] = $conversation->url(); + $redirect_same_page = true; + } else { + $response['redirect_url'] = $this->getRedirectUrl($request, $conversation, $user); + } + + $conversation->changeStatus($new_status, $user); + + $response['status'] = 'success'; + // Flash + $flash_message = __('Status updated'); + if (!$redirect_same_page || $response['redirect_url'] != $conversation->url()) { + $flash_message .= '  '.__('View').''; + } + \Session::flash('flash_success_floating', $flash_message); + + $response['msg'] = __('Status updated'); + } + break; + + // Send reply, new conversation, add note or forward + case 'send_reply': + + $mailbox = Mailbox::findOrFail($request->mailbox_id); + + if (!$response['msg'] && !$user->can('view', $mailbox)) { + $response['msg'] = __('Not enough permissions'); + } + + $conversation = null; + if (!$response['msg'] && !empty($request->conversation_id)) { + $conversation = Conversation::find($request->conversation_id); + if ($conversation && !$user->can('view', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + } + $new = false; + if (empty($request->conversation_id)) { + $new = true; + } + + $is_note = false; + if (!empty($request->is_note)) { + $is_note = true; + } + + // Conversation type. + $type = Conversation::TYPE_EMAIL; + if (!empty($request->type)) { + $type = (int)$request->type; + } elseif ($conversation) { + $type = $conversation->type; + } + + $is_phone = false; + if ($type == Conversation::TYPE_PHONE) { + $is_phone = true; + } + + $is_create = false; + if (!empty($request->is_create)) { + //if ($new || ($from_draft && $conversation->threads_count == 1)) { + $is_create = $request->is_create; + } + + $is_forward = false; + if (!empty($request->subtype) && (int)$request->subtype == Thread::SUBTYPE_FORWARD) { + $is_forward = true; + } + + $is_multiple = false; + if (!empty($request->multiple_conversations)) { + $is_multiple = true; + } + + // If reply is being created from draft, there is already thread created + $thread = null; + $from_draft = false; + if ((!$is_note || $is_phone) && !$response['msg'] && !empty($request->thread_id)) { + $thread = Thread::find($request->thread_id); + if ($thread && (!$conversation || $thread->conversation_id != $conversation->id)) { + $response['msg'] = __('Incorrect thread'); + } else { + $from_draft = true; + } + } + + if (!$response['msg']) { + if ($thread && $from_draft && $thread->state == Thread::STATE_PUBLISHED) { + $response['msg'] = __('Message has been already sent. Please discard this draft.'); + } + } + + // Validate form + if (!$response['msg']) { + if ($new) { + if ($type == Conversation::TYPE_EMAIL) { + $validator = Validator::make($request->all(), [ + 'to' => 'required|array', + 'subject' => 'required|string|max:998', + 'body' => 'required|string', + 'cc' => 'nullable|array', + 'bcc' => 'nullable|array', + ]); + } else { + // Phone conversation. + $validator = Validator::make($request->all(), [ + 'name' => 'required|string', + 'subject' => 'required|string|max:998', + 'body' => 'required|string', + 'phone' => 'nullable|string', + 'to_email' => 'nullable|string', + ]); + } + } else { + $validator = Validator::make($request->all(), [ + 'body' => 'required|string', + 'cc' => 'nullable|array', + 'bcc' => 'nullable|array', + ]); + } + + if ($validator->fails()) { + foreach ($validator->errors()->getMessages()as $errors) { + foreach ($errors as $field => $message) { + $response['msg'] .= $message.' '; + } + } + } + } + + $body = $request->body; + + // Replace base64 images with attachment URLs in case text + // was copy and pasted into the editor. + // https://github.com/freescout-helpdesk/freescout/issues/3057 + $body = Thread::replaceBase64ImagesWithAttachments($body); + + // List of emails. + $to_array = []; + if ($is_forward) { + $to_array = Conversation::sanitizeEmails($request->to_email); + } else { + $to_array = Conversation::sanitizeEmails($request->to); + } + // Check To + if (!$response['msg'] && $new && !$is_phone) { + if (!$to_array) { + $response['msg'] .= __('Incorrect recipients'); + } + } + + // Check max. message size. + if (!$response['msg']) { + $max_message_size = (int)config('app.max_message_size'); + if ($max_message_size) { + // Todo: take into account conversation history. + $message_size = mb_strlen($body, '8bit'); + + // Calculate attachments size. + $attachments_ids = array_merge($request->attachments ?? [], $request->embeds ?? []); + + if (count($attachments_ids)) { + $attachments_to_check = Attachment::select('size')->whereIn('id', $attachments_ids)->get(); + foreach ($attachments_to_check as $attachment) { + $message_size += (int)$attachment->size; + } + } + + if ($message_size > $max_message_size*1024*1024) { + $response['msg'] = __('Message is too large — :info. Please shorten your message or remove some attachments.', ['info' => __('Max. Message Size').': '.$max_message_size.' MB']); + } + } + } + + if (!$response['msg']) { + + // Get attachments info + // Delete removed attachments. + $attachments_info = $this->processReplyAttachments($request); + + // Determine redirect. + // Must be done before updating current conversation's status or assignee. + // Redirect URL for new no saved yet conversation is determined below. + if (!$new) { + $response['redirect_url'] = $this->getRedirectUrl($request, $conversation, $user); + } + + // Conversation + $now = date('Y-m-d H:i:s'); + $status_changed = false; + $user_changed = false; + if ($new) { + // New conversation + $conversation = new Conversation(); + $conversation->type = $type; + $conversation->subject = $request->subject; + $conversation->setPreview($body); + $conversation->mailbox_id = $request->mailbox_id; + $conversation->created_by_user_id = auth()->user()->id; + $conversation->source_via = Conversation::PERSON_USER; + $conversation->source_type = Conversation::SOURCE_TYPE_WEB; + } else { + // Reply or note + if ((int) $request->status != (int) $conversation->status) { + $status_changed = true; + } + if (!empty($request->subject)) { + $conversation->subject = $request->subject; + } + // When switching from regular message to phone and message sent + // without saving a draft type need to be saved here. + // Or vise versa. + if (($conversation->type == Conversation::TYPE_EMAIL && $type == Conversation::TYPE_PHONE) + || ($conversation->type == Conversation::TYPE_PHONE && $type == Conversation::TYPE_EMAIL) + ) { + $conversation->type = $type; + } + // Allow to convert phone conversations into email conversations. + if ($conversation->isPhone() && !$is_note && $conversation->customer + && $customer_email = $conversation->customer->getMainEmail() + ) { + $conversation->type = Conversation::TYPE_EMAIL; + $conversation->customer_email = $customer_email; + $is_phone = false; + } + } + + if ($attachments_info['has_attachments']) { + $conversation->has_attachments = true; + } + + // Customer can be empty in existing conversation if this is a draft. + $customer_email = ''; + $customer = null; + + if ($is_phone && $is_create) { + // Phone. + $phone_customer_data = $this->processPhoneCustomer($request); + + $customer_email = $phone_customer_data['customer_email']; + $customer = $phone_customer_data['customer']; + if (!$conversation->customer_id) { + $conversation->customer_id = $customer->id; + } + } else { + // Email or reply to a phone conversation. + if (!empty($to_array)) { + $customer_email = $to_array[0]; + } elseif (!$conversation->customer_email + && ($conversation->isEmail() || $conversation->isPhone()) + && $conversation->customer_id + && $conversation->customer + ) { + // When replying to a phone conversation, we need to + // set 'customer_email' for the conversation. + $customer_email = $conversation->customer->getMainEmail(); + } + if (!$conversation->customer_id) { + $customer = Customer::create($customer_email); + $conversation->customer_id = $customer->id; + } else { + $customer = $conversation->customer; + } + } + if ($customer_email && !$is_note && !$is_forward) { + $conversation->customer_email = $customer_email; + } + + $prev_status = $conversation->status; + + $conversation->status = $request->status; + + if (($prev_status != $conversation->status || $is_create) + && $conversation->status == Conversation::STATUS_CLOSED + ) { + $conversation->closed_by_user_id = $user->id; + $conversation->closed_at = date('Y-m-d H:i:s'); + } + + // We need to set state, as it may have been a draft. + $prev_state = $conversation->state; + $conversation->state = Conversation::STATE_PUBLISHED; + + // Set assignee + $prev_user_id = $conversation->user_id; + if ((int) $request->user_id != -1) { + // Check if user has access to the current mailbox + if ((int) $conversation->user_id != (int) $request->user_id && $mailbox->userHasAccess($request->user_id)) { + $conversation->user_id = $request->user_id; + $user_changed = true; + } + } else { + $conversation->user_id = null; + } + + // To is a single email string. + $to = ''; + // List of emails. + $to_list = []; + if ($is_forward) { + if (empty($request->to_email[0])) { + $response['msg'] = __('Please specify a recipient.'); + break; + } + $to = $request->to_email[0]; + } else { + if (!empty($request->to)) { + // When creating a new conversation, to is a list of emails. + if (is_array($request->to)) { + $to = $request->to[0]; + } else { + $to = $request->to; + } + } else { + $to = $conversation->customer_email; + } + } + + if (!$is_note && !$is_forward) { + // Save extra recipients to CC + if ($is_create && !$is_multiple && count($to_array) > 1) { + $conversation->setCc(array_merge(Conversation::sanitizeEmails($request->cc), $to_array)); + } else { + if (!$is_multiple) { + $conversation->setCc(array_merge(Conversation::sanitizeEmails($request->cc), [$to])); + } else { + $conversation->setCc(Conversation::sanitizeEmails($request->cc)); + } + } + $conversation->setBcc($request->bcc); + $conversation->last_reply_at = $now; + $conversation->last_reply_from = Conversation::PERSON_USER; + $conversation->user_updated_at = $now; + } + if ($conversation->isPhone() && $is_note) { + $conversation->last_reply_at = $now; + $conversation->last_reply_from = Conversation::PERSON_USER; + } + $conversation->updateFolder(); + if ($from_draft) { + // Increment number of replies in conversation + $conversation->threads_count++; + // We need to set preview here as when conversation is created from draft, + // ThreadObserver::created() method is not called. + $conversation->setPreview($body); + } + $conversation->save(); + + // Redirect URL for new not saved yet conversation must be determined here. + if ($new) { + $response['redirect_url'] = $this->getRedirectUrl($request, $conversation, $user); + } + + // Fire events + \Eventy::action('conversation.send_reply_save', $conversation, $request); + + if (!$new) { + if ($status_changed) { + event(new ConversationStatusChanged($conversation)); + \Eventy::action('conversation.status_changed', $conversation, $user, $changed_on_reply = true, $prev_status); + } + if ($user_changed) { + event(new ConversationUserChanged($conversation, $user)); + \Eventy::action('conversation.user_changed', $conversation, $user, $prev_user_id); + } + } + + if ($conversation->state != $prev_state) { + \Eventy::action('conversation.state_changed', $conversation, $user, $prev_state); + } + + // Create thread + if (!$thread) { + $thread = new Thread(); + $thread->conversation_id = $conversation->id; + if ($is_note || $is_forward) { + $thread->type = Thread::TYPE_NOTE; + } else { + $thread->type = Thread::TYPE_MESSAGE; + } + $thread->source_via = Thread::PERSON_USER; + $thread->source_type = Thread::SOURCE_TYPE_WEB; + } else { + if ($is_forward || $is_phone) { + $thread->type = Thread::TYPE_NOTE; + } else { + $thread->type = Thread::TYPE_MESSAGE; + } + $thread->created_at = $now; + } + if ($new) { + $thread->first = true; + } + $thread->user_id = $conversation->user_id; + $thread->status = $request->status; + $thread->state = Thread::STATE_PUBLISHED; + $thread->customer_id = $customer->id; + $thread->created_by_user_id = auth()->user()->id; + $thread->edited_by_user_id = null; + $thread->edited_at = null; + $thread->body = $body; + if ($is_create && !$is_multiple && count($to_array) > 1) { + $thread->setTo($to_array); + } else { + $thread->setTo($to); + } + // We save CC and BCC as is and filter emails when sending replies + $thread->setCc($request->cc); + $thread->setBcc($request->bcc); + if ($attachments_info['has_attachments'] && !$is_forward) { + $thread->has_attachments = true; + } + if (!empty($request->saved_reply_id)) { + $thread->saved_reply_id = $request->saved_reply_id; + } + + $forwarded_conversations = []; + $forwarded_threads = []; + + if ($is_forward) { + // Create forwarded conversations. + foreach ($to_array as $recipient_email) { + $forwarded_conversation = $conversation->replicate(); + $forwarded_conversation->type = Conversation::TYPE_EMAIL; + $forwarded_conversation->setPreview($thread->body); + $forwarded_conversation->created_by_user_id = auth()->user()->id; + $forwarded_conversation->source_via = Conversation::PERSON_USER; + $forwarded_conversation->source_type = Conversation::SOURCE_TYPE_WEB; + $forwarded_conversation->threads_count = 0; // Counter will be incremented in ThreadObserver. + $forwarded_customer = Customer::create($recipient_email); + $forwarded_conversation->customer_id = $forwarded_customer->id; + // Reload customer object, otherwise it stores previous customer. + $forwarded_conversation->load('customer'); + $forwarded_conversation->customer_email = $recipient_email; + $forwarded_conversation->subject = 'Fwd: '.$forwarded_conversation->subject; + //$forwarded_conversation->setCc(array_merge(Conversation::sanitizeEmails($request->cc), [$to])); + $forwarded_conversation->setCc(Conversation::sanitizeEmails($request->cc)); + $forwarded_conversation->setBcc($request->bcc); + $forwarded_conversation->last_reply_at = $now; + $forwarded_conversation->last_reply_from = Conversation::PERSON_USER; + $forwarded_conversation->user_updated_at = $now; + if ($attachments_info['has_attachments']) { + $forwarded_conversation->has_attachments = true; + } + $forwarded_conversation->updateFolder(); + $forwarded_conversation->save(); + + $forwarded_thread = $thread->replicate(); + + $forwarded_conversations[] = $forwarded_conversation; + $forwarded_threads[] = $forwarded_thread; + } + + // Set forwarding meta data. + // todo: store array of numbers and IDs. + $thread->subtype = Thread::SUBTYPE_FORWARD; + $thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_NUMBER, $forwarded_conversation->number); + $thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_ID, $forwarded_conversation->id); + } + + // Conversation history. + if (!empty($request->conv_history)) { + if ($request->conv_history != 'global') { + if ($is_forward && !empty($forwarded_threads)) { + foreach ($forwarded_threads as $forwarded_thread) { + $forwarded_thread->setMeta(Thread::META_CONVERSATION_HISTORY, $request->conv_history); + } + } else { + $thread->setMeta(Thread::META_CONVERSATION_HISTORY, $request->conv_history); + } + } + } + + // From (mailbox alias). + if (!empty($request->from_alias)) { + $thread->from = $request->from_alias; + } + + \Eventy::action('thread.before_save_from_request', $thread, $request); + $thread->save(); + + // Save forwarded thread. + if ($is_forward) { + foreach ($forwarded_conversations as $i => $forwarded_conversation) { + $forwarded_thread = $forwarded_threads[$i]; + + $forwarded_thread->conversation_id = $forwarded_conversation->id; + $forwarded_thread->type = Thread::TYPE_MESSAGE; + $forwarded_thread->subtype = null; + if ($attachments_info['has_attachments']) { + $forwarded_thread->has_attachments = true; + } + $forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_CONVERSATION_NUMBER, $conversation->number); + $forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_CONVERSATION_ID, $conversation->id); + $forwarded_thread->setMeta(Thread::META_FORWARD_PARENT_THREAD_ID, $thread->id); + \Eventy::action('send_reply.before_save_forwarded_thread', $forwarded_thread, $request); + $forwarded_thread->save(); + } + } + + // If thread has been created from draft, remove the draft + // if ($request->thread_id) { + // $draft_thread = Thread::find($request->thread_id); + // if ($draft_thread) { + // $draft_thread->delete(); + // } + // } + + if ($from_draft) { + // Remove conversation from drafts folder if needed + $conversation->maybeRemoveFromDrafts(); + } + + // Update folders counters + $conversation->mailbox->updateFoldersCounters(); + + $response['status'] = 'success'; + + // Set thread_id for uploaded attachments + if ($attachments_info['attachments']) { + if ($is_forward) { + // Copy attachments for each thread. + if (count($forwarded_threads) > 1) { + $attachments = Attachment::whereIn('id', $attachments_info['attachments'])->get(); + } + foreach ($forwarded_threads as $i => $forwarded_thread) { + if ($i == 0) { + Attachment::whereIn('id', $attachments_info['attachments'])->update(['thread_id' => $forwarded_thread->id]); + } else { + foreach ($attachments as $attachment) { + $attachment->duplicate($forwarded_thread->id); + } + } + } + } else { + Attachment::whereIn('id', $attachments_info['attachments']) + ->where('thread_id', null) + ->update(['thread_id' => $thread->id]); + } + } + + // Follow conversation if it's assigned to someone else. + if (!$is_create && !$new && !$is_forward && !$is_note + && $conversation->user_id != $user->id + ) { + $user->followConversation($conversation->id); + } + + // When user creates a new conversation it may be saved as draft first. + if ($is_create) { + // New conversation. + event(new UserCreatedConversation($conversation, $thread)); + \Eventy::action('conversation.created_by_user_can_undo', $conversation, $thread); + // After Conversation::UNDO_TIMOUT period trigger final event. + \Helper::backgroundAction('conversation.created_by_user', [$conversation, $thread], now()->addSeconds(Conversation::UNDO_TIMOUT)); + } elseif ($is_forward) { + // Forward. + // Notifications to users not sent. + event(new UserAddedNote($conversation, $thread)); + foreach ($forwarded_conversations as $i => $forwarded_conversation) { + $forwarded_thread = $forwarded_threads[$i]; + + // To send email with forwarded conversation. + event(new UserReplied($forwarded_conversation, $forwarded_thread)); + \Eventy::action('conversation.user_forwarded_can_undo', $conversation, $thread, $forwarded_conversation, $forwarded_thread); + // After Conversation::UNDO_TIMOUT period trigger final event. + \Helper::backgroundAction('conversation.user_forwarded', [$conversation, $thread, $forwarded_conversation, $forwarded_thread], now()->addSeconds(Conversation::UNDO_TIMOUT)); + } + } elseif ($is_note) { + // Note. + event(new UserAddedNote($conversation, $thread)); + \Eventy::action('conversation.note_added', $conversation, $thread); + } else { + // Reply. + event(new UserReplied($conversation, $thread)); + \Eventy::action('conversation.user_replied_can_undo', $conversation, $thread); + // After Conversation::UNDO_TIMOUT period trigger final event. + \Helper::backgroundAction('conversation.user_replied', [$conversation, $thread], now()->addSeconds(Conversation::UNDO_TIMOUT)); + } + + // Send new conversation separately to each customer. + if ($is_create && count($to_array) > 1 && $is_multiple) { + $prev_customers_ids = []; + foreach ($to_array as $i => $customer_email) { + // Skip first email, as conversation has already been created for it. + if ($i == 0) { + continue; + } + // Get customer by email. + $customer_tmp = Customer::getByEmail($customer_email); + // Skip same customers. + if ($customer_tmp && in_array($customer_tmp->id, $prev_customers_ids)) { + continue; + } + + if (!$customer_tmp) { + $customer_tmp = Customer::create($customer_email); + } + + $prev_customers_ids[] = $customer_tmp->id; + + // Copy conversation and thread. + $conversation_copy = $conversation->replicate(); + $thread_copy = $thread->replicate(); + + // Save conversation. + $conversation_copy->threads_count = 0; + $conversation_copy->customer_id = $customer_tmp->id; + // Reload customer, otherwise all recipients will have the same name. + $conversation_copy->load('customer'); + $conversation_copy->customer_email = $customer_email; + $conversation_copy->has_attachments = $conversation->has_attachments; + $conversation_copy->push(); + + $thread_copy->conversation_id = $conversation_copy->id; + $thread_copy->customer_id = $customer_tmp->id; + $thread_copy->has_attachments = $conversation->has_attachments; + $thread_copy->setTo($customer_email); + // Reload the conversation, otherwise Thread observer will be + // increasing threads_count for the first conversation. + $thread_copy->load('conversation'); + $thread_copy->push(); + + // Copy attachments. + if (!empty($attachments_info['attachments'])) { + $attachments = Attachment::whereIn('id', $attachments_info['attachments'])->get(); + foreach ($attachments as $attachment) { + $attachment->duplicate($thread_copy->id); + } + } + + // Events. + // todo: allow to undo all emails + event(new UserCreatedConversation($conversation_copy, $thread_copy)); + \Eventy::action('conversation.created_by_user_can_undo', $conversation_copy, $thread_copy); + // After Conversation::UNDO_TIMOUT period trigger final event. + \Helper::backgroundAction('conversation.created_by_user', [$conversation_copy, $thread_copy], now()->addSeconds(Conversation::UNDO_TIMOUT)); + } + } + + // Compose flash message. + $show_view_link = true; + if (!empty($request->after_send) && $request->after_send == MailboxUser::AFTER_SEND_STAY) { + $show_view_link = false; + } + + $flash_vars = ['%tag_start%' => '', '%tag_end%' => '', '%view_start%' => ' ', '%a_end%' => ' ', '%undo_start%' => ' ']; + + if ($is_phone) { + $flash_type = 'warning'; + if ($show_view_link) { + $flash_text = __(':%tag_start%Conversation created:%tag_end% :%view_start%View:%a_end% or :%undo_start%Undo:%a_end%', $flash_vars); + } else { + $flash_text = ''.__('Conversation created').''; + } + } elseif ($is_note) { + $flash_type = 'warning'; + if ($show_view_link) { + $flash_text = __(':%tag_start%Note added:%tag_end% :%view_start%View:%a_end%', $flash_vars); + } else { + $flash_text = ''.__('Note added').''; + } + } else { + $flash_type = 'success'; + if ($show_view_link) { + $flash_text = __(':%tag_start%Email Sent:%tag_end% :%view_start%View:%a_end% or :%undo_start%Undo:%a_end%', $flash_vars); + } else { + $flash_text = __(':%tag_start%Email Sent:%tag_end% :%undo_start%Undo:%a_end%', $flash_vars); + } + } + + \Session::flash('flash_'.$flash_type.'_floating', $flash_text); + } + break; + + // Save draft (automatically or by click) of a new conversation or reply. + case 'save_draft': + + $mailbox = Mailbox::findOrFail($request->mailbox_id); + + if (!$response['msg'] && !$user->can('view', $mailbox)) { + $response['msg'] = __('Not enough permissions'); + } + + $conversation = null; + $new = true; + if (!$response['msg'] && !empty($request->conversation_id)) { + $conversation = Conversation::find($request->conversation_id); + if ($conversation && !$user->can('view', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } else { + $new = false; + } + } + + $is_create = false; + if (!empty($request->is_create)) { + $is_create = true; + } + + $thread = null; + $new_thread = true; + if (!$response['msg'] && !empty($request->thread_id)) { + $thread = Thread::find($request->thread_id); + if ($thread && (!$conversation || $thread->conversation_id != $conversation->id)) { + $response['msg'] = __('Incorrect thread'); + } else { + $new_thread = false; + } + } + + // Check if thread has been sent (in other window for example). + if (!$response['msg']) { + if ($thread && $thread->state == Thread::STATE_PUBLISHED) { + $response['msg'] = __('Message has been already sent. Please discard this draft.'); + } + } + + // To prevent creating draft after reply has been created. + if (!$response['msg'] && $conversation) { + // Check if the last thread has same content as the new one. + $last_thread = $conversation->getLastThread([Thread::TYPE_MESSAGE, Thread::TYPE_NOTE]); + + if ($last_thread + && $last_thread->created_by_user_id == $user->id + && $last_thread->body == $request->body + ) { + //\Log::error("You've already sent this message just recently."); + $response['msg'] = __("You've already sent this message just recently."); + } + } + + // Validation is not needed on draft create, fields can be empty + + if (!$response['msg']) { + + // Get attachments info + $attachments_info = $this->processReplyAttachments($request); + + // Conversation + $now = date('Y-m-d H:i:s'); + + if ($new) { + $conversation = new Conversation(); + } + + // To is a single email or array of emails. + $to = ''; + + if ($new || $is_create) { + // New conversation + $customer_email = ''; + $customer = null; + + $type = Conversation::TYPE_EMAIL; + if (!empty($request->type)) { + $type = (int)$request->type; + } + + if ($type == Conversation::TYPE_PHONE) { + // Phone. + $phone_customer_data = $this->processPhoneCustomer($request); + + $customer_email = $phone_customer_data['customer_email']; + $customer = $phone_customer_data['customer']; + } else { + // Email. + // Now instead of customer_email we store emails in thread->to. + $to_array = Conversation::sanitizeEmails($request->to); + if (count($to_array)) { + if (count($to_array) == 1) { + //$customer_email = array_first($to_array); + $to = array_first($to_array); + $customer = Customer::create($customer_email); + } else { + // Creating a conversation to multiple customers + // In customer_email temporary store a list of customer emails. + //$customer_email = implode(',', $to_array); + $to = $to_array; + + // Keep $customer as null. + // When conversation will be sent, separate conversation + // will be created for each customer. + $customer = null; + } + } + } + + $conversation->type = $type; + $conversation->state = Conversation::STATE_DRAFT; + $conversation->status = $request->status; + $conversation->subject = $request->subject; + $conversation->setPreview($request->body); + if ($attachments_info['has_attachments']) { + $conversation->has_attachments = true; + } + $conversation->mailbox_id = $request->mailbox_id; + // Customer may be empty in draft + if ($customer) { + $conversation->customer_id = $customer->id; + } + $conversation->customer_email = $customer_email; + $conversation->created_by_user_id = auth()->user()->id; + $conversation->source_via = Conversation::PERSON_USER; + $conversation->source_type = Conversation::SOURCE_TYPE_WEB; + } else { + // Reply + $customer = $conversation->customer; + } + + // New draft conversation is not assigned to anybody + //$conversation->user_id = null; + + if (empty($request->to) || !is_array($request->to)) { + if (!empty($request->to)) { + // New conversation. + $to = $request->to; + } elseif (!empty($request->to_email)) { + // Forwarding. + $to = $request->to_email; + } else { + $to = $conversation->customer_email; + } + } + + // Conversation type. + if (!empty($request->type) && array_key_exists((int)$request->type, Conversation::$types)) { + $conversation->type = (int)$request->type; + } + + // Save extra recipients to CC + if ($is_create) { + //$conversation->setCc(array_merge(Conversation::sanitizeEmails($request->cc), (is_array($to) ? $to : [$to]))); + $conversation->setCc($request->cc); + $conversation->setBcc($request->bcc); + } + // $conversation->last_reply_at = $now; + // $conversation->last_reply_from = Conversation::PERSON_USER; + // $conversation->user_updated_at = $now; + $conversation->updateFolder(); + + $conversation->save(); + + // Create thread + if (empty($thread)) { + $thread = new Thread(); + $thread->conversation_id = $conversation->id; + $thread->user_id = auth()->user()->id; + //$thread->type = Thread::TYPE_MESSAGE; + if ($new) { + $thread->first = true; + } + //$thread->status = $request->status; + $thread->state = Thread::STATE_DRAFT; + + $thread->source_via = Thread::PERSON_USER; + $thread->source_type = Thread::SOURCE_TYPE_WEB; + if ($customer) { + $thread->customer_id = $customer->id; + } + $thread->created_by_user_id = auth()->user()->id; + // User is forwarding a conversation. + if (!empty($request->subtype) && (int)$request->subtype) { + $thread->subtype = $request->subtype; + } + } + if ($attachments_info['has_attachments']) { + $thread->has_attachments = true; + } + // Thread type. + if ($is_create && !empty($request->is_note)) { + $thread->type = Thread::TYPE_NOTE; + } else { + $thread->type = Thread::TYPE_MESSAGE; + } + $thread->from = $request->from_alias ?? null; + $thread->body = $request->body; + $thread->setTo($to); + // We save CC and BCC as is and filter emails when sending replies + $thread->setCc($request->cc); + $thread->setBcc($request->bcc); + // Set edited info + if ($thread->created_by_user_id != $user->id) { + $thread->edited_by_user_id = $user->id; + $thread->edited_at = $now; + } + $thread->save(); + + $conversation->addToFolder(Folder::TYPE_DRAFTS); + + $response['conversation_id'] = $conversation->id; + $response['customer_id'] = $conversation->customer_id; + $response['thread_id'] = $thread->id; + $response['number'] = $conversation->number; + + $response['status'] = 'success'; + + // Set thread_id for uploaded attachments + if ($attachments_info['attachments']) { + Attachment::whereIn('id', $attachments_info['attachments']) + ->where('thread_id', null) + ->update(['thread_id' => $thread->id]); + } + + // Update folder counter. + $conversation->mailbox->updateFoldersCounters(Folder::TYPE_DRAFTS); + + if ($new) { + event(new UserCreatedConversationDraft($conversation, $thread)); + } elseif ($new_thread) { + event(new UserCreatedThreadDraft($conversation, $thread)); + } + + $response['status'] = 'success'; + } + + // 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(); + + break; + + // Discard draft (from new conversation, from reply or conversation) + case 'discard_draft': + + $thread = Thread::find($request->thread_id); + + if (!$thread) { + // Discarding nont saved yet draft + $response['status'] = 'success'; + + // Discarding a new conversation being created from thread + if (!empty($request->from_thread_id)) { + $original_thread = Thread::find($request->from_thread_id); + if ($original_thread && $original_thread->conversation_id) { + // Open original conversation + $response['redirect_url'] = route('conversations.view', ['id' => $original_thread->conversation_id]); + } + } + break; + //$response['msg'] = __('Thread not found'); + } + if (!$response['msg'] && !$user->can('view', $thread->conversation)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + $conversation = $thread->conversation; + + if ($conversation->state == Conversation::STATE_DRAFT) { + // New conversation draft being discarded + $folder_id = $conversation->getCurrentFolder(); + $response['redirect_url'] = route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $folder_id]); + + $mailbox = $conversation->mailbox; + + $conversation->removeFromFolder(Folder::TYPE_DRAFTS); + $conversation->removeFromFolder(Folder::TYPE_STARRED, $user->id); + $mailbox->updateFoldersCounters(Folder::TYPE_DRAFTS); + $conversation->deleteThreads(); + $conversation->delete(); + + // Draft may be present in Starred folder. + Conversation::clearStarredByUserCache($user->id, $mailbox->id); + $mailbox->updateFoldersCounters(Folder::TYPE_STARRED); + + $flash_message = __('Deleted draft'); + \Session::flash('flash_success_floating', $flash_message); + } else { + // https://github.com/freescout-helpdesk/freescout/issues/2873 + if ($thread->state == Thread::STATE_DRAFT) { + // Just remove the thread, no need to reload the page + $thread->deleteThread(); + // Remove conversation from drafts folder if needed + $removed_from_folder = $conversation->maybeRemoveFromDrafts(); + if ($removed_from_folder) { + $conversation->mailbox->updateFoldersCounters(Folder::TYPE_DRAFTS); + } + } + } + + $response['status'] = 'success'; + } + break; + + // Save draft (automatically or by click) + case 'load_draft': + $thread = Thread::find($request->thread_id); + if (!$thread) { + $response['msg'] = __('Thread not found'); + } elseif ($thread->state != Thread::STATE_DRAFT) { + $response['msg'] = __('Thread is not in a draft state'); + } else { + if (!$user->can('view', $thread->conversation)) { + $response['msg'] = __('Not enough permissions'); + } + } + + if (!$response['msg']) { + + // Build attachments list. + $attachments = []; + foreach ($thread->attachments as $attachment) { + $attachments[] = [ + 'id' => $attachment->id, + 'name' => $attachment->file_name, + 'size' => $attachment->size, + 'url' => $attachment->url(), + ]; + } + + $response['data'] = [ + 'thread_id' => $thread->id, + 'from_alias' => $thread->from, + 'to' => $thread->getToFirst(), + 'cc' => $thread->getCcArray(), + 'bcc' => $thread->getBccArray(), + 'body' => $thread->body, + 'is_forward' => (int)$thread->isForward(), + 'attachments' => $attachments, + ]; + $response['status'] = 'success'; + } + break; + + // Load attachments from all threads in conversation + // when forwarding or creating a new conversation. + case 'load_attachments': + $conversation = Conversation::find($request->conversation_id); + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } else { + if (!$user->can('view', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + } + + if (!$response['msg']) { + // Build attachments list. + $attachments = []; + + if ($conversation->has_attachments) { + foreach ($conversation->threads as $thread) { + if ($thread->has_attachments) { + foreach ($thread->attachments as $attachment) { + if ($request->is_forwarding == 'true') { + $attachment_copy = $attachment->duplicate($thread->id); + } else { + $attachment_copy = $attachment; + } + + $attachments[] = [ + 'id' => $attachment_copy->id, + 'name' => $attachment_copy->file_name, + 'size' => $attachment_copy->size, + 'url' => $attachment_copy->url(), + ]; + } + } + } + } + + $response['data'] = [ + 'attachments' => $attachments, + ]; + $response['status'] = 'success'; + } + break; + + // Save default redirect + case 'save_after_send': + $mailbox = Mailbox::find($request->mailbox_id); + if (!$mailbox) { + $response['msg'] .= __('Mailbox not found'); + } elseif (!$mailbox->userHasAccess($user->id)) { + $response['msg'] .= __('Action not authorized'); + } + if (!$response['msg']) { + $mailbox_user = $user->mailboxesWithSettings()->where('mailbox_id', $request->mailbox_id)->first(); + if (!$mailbox_user) { + // Admin may not be connected to the mailbox yet + $user->mailboxes()->attach($request->mailbox_id); + // $mailbox_user = new MailboxUser(); + // $mailbox_user->mailbox_id = $mailbox->id; + // $mailbox_user->user_id = $user->id; + $mailbox_user = $user->mailboxesWithSettings()->where('mailbox_id', $request->mailbox_id)->first(); + } + $mailbox_user->settings->after_send = $request->value; + $mailbox_user->settings->save(); + + $response['status'] = 'success'; + } + break; + + // Conversations navigation + case 'conversations_pagination': + if (!empty($request->filter)) { + $response = $this->ajaxConversationsFilter($request, $response, $user); + } else { + $response = $this->ajaxConversationsPagination($request, $response, $user); + } + break; + + // Change conversation customer + case 'conversation_change_customer': + $conversation = Conversation::find($request->conversation_id); + $customer_email = $request->customer_email; + + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } + if (!$response['msg'] && !$user->can('update', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + if (!$response['msg'] && !$conversation->mailbox->userHasAccess($user->id)) { + $response['msg'] = __('Not enough permissions'); + } + + $conversation->changeCustomer($customer_email, null, $user); + + $response['status'] = 'success'; + \Session::flash('flash_success_floating', __('Customer changed')); + + break; + + // Star/unstar conversation + case 'star_conversation': + $conversation = Conversation::find($request->conversation_id); + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } elseif (!$user->can('view', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + if ($request->sub_action == 'star') { + $conversation->star($user); + } else { + $conversation->unstar($user); + } + $response['status'] = 'success'; + } + break; + + // Delete conversation (move to DELETED folder) + case 'delete_conversation': + $conversation = Conversation::find($request->conversation_id); + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } elseif (!$user->can('delete', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + $folder_id = $conversation->getCurrentFolder(); + + $conversation->deleteToFolder($user); + + $response['redirect_url'] = route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $folder_id]); + + $response['status'] = 'success'; + + \Session::flash('flash_success_floating', __('Conversation deleted')); + } + break; + + // Delete conversation forever + case 'delete_conversation_forever': + $conversation = Conversation::find($request->conversation_id); + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } elseif (!$user->can('delete', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + $folder_id = $conversation->getCurrentFolder(); + $mailbox = $conversation->mailbox; + + $conversation->deleteForever(); + + // Recalculate only old and new folders + $mailbox->updateFoldersCounters(); + + $response['redirect_url'] = route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $folder_id]); + + $response['status'] = 'success'; + + \Session::flash('flash_success_floating', __('Conversation deleted')); + } + break; + + // Restore conversation + case 'restore_conversation': + $conversation = Conversation::find($request->conversation_id); + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } elseif (!$user->can('delete', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + $folder_id = $conversation->folder_id; + $prev_state = $conversation->state; + $conversation->state = Conversation::STATE_PUBLISHED; + $conversation->user_updated_at = date('Y-m-d H:i:s'); + $conversation->updateFolder(); + $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_RESTORE_TICKET; + $thread->source_via = Thread::PERSON_USER; + // todo: this need to be changed for API + $thread->source_type = Thread::SOURCE_TYPE_WEB; + $thread->customer_id = $conversation->customer_id; + $thread->created_by_user_id = $user->id; + $thread->save(); + + // Recalculate only old and new folders + $conversation->mailbox->updateFoldersCounters(); + + if ($prev_state != $conversation->state) { + \Eventy::action('conversation.state_changed', $conversation, $user, $prev_state); + } + + $response['status'] = 'success'; + + \Session::flash('flash_success_floating', __('Conversation restored')); + } + break; + + // Load data to edit thread. + case 'load_edit_thread': + $thread = Thread::find($request->thread_id); + if (!$thread) { + $response['msg'] = __('Thread not found'); + } elseif (!$user->can('edit', $thread)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + $data = [ + 'thread' => $thread + ]; + $response['html'] = \View::make('conversations/partials/edit_thread')->with($data)->render(); + + $response['status'] = 'success'; + } + break; + + // Load data to edit thread. + case 'save_edit_thread': + $thread = Thread::find($request->thread_id); + if (!$thread) { + $response['msg'] = __('Conversation not found'); + } elseif (!$user->can('edit', $thread)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + if (!$thread->body_original) { + $thread->body_original = $thread->body; + } + $thread->body = $request->body; + $thread->edited_by_user_id = $user->id; + $thread->edited_at = date('Y-m-d H:i:s'); + $response['body'] = $thread->getCleanBody(); + + if (strip_tags($response['body'])) { + + // Update the preview for the conversation if needed. + $last_thread = $thread->conversation->getLastThread([Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_NOTE]); + if ($last_thread && $last_thread->id == $thread->id) { + $thread->conversation->setPreview($thread->body); + $thread->conversation->save(); + } + $thread->save(); + + $response['status'] = 'success'; + } else { + $response['msg'] = __('Message cannot be empty'); + } + } + break; + + // Delete thread (note). + case 'delete_thread': + $thread = Thread::find($request->thread_id); + if (!$thread || !$thread->isNote()) { + $response['msg'] = __('Thread not found'); + } elseif (!$user->can('delete', $thread)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + $thread->deleteThread(); + $response['status'] = 'success'; + } + break; + + // Change conversations user + case 'bulk_conversation_change_user': + + $conversations = Conversation::findMany($request->conversation_id); + + $new_user_id = (int) $request->user_id; + + if (!$response['msg']) { + foreach ($conversations as $conversation) { + if (!$user->can('update', $conversation)) { + continue; + } + if ((int) $new_user_id != -1 && !$conversation->mailbox->userHasAccess($new_user_id)) { + continue; + } + + $conversation->changeUser($new_user_id, $user); + } + + $response['status'] = 'success'; + // Flash + $flash_message = __('Assignee updated'); + \Session::flash('flash_success_floating', $flash_message); + + $response['msg'] = __('Assignee updated'); + } + break; + + // Change conversations status + case 'bulk_conversation_change_status': + $conversations = Conversation::findMany($request->conversation_id); + + $new_status = (int) $request->status; + + if (!in_array((int) $request->status, array_keys(Conversation::$statuses))) { + $response['msg'] = __('Incorrect status'); + } + + if (!$response['msg']) { + foreach ($conversations as $conversation) { + if (!$user->can('update', $conversation)) { + continue; + } + + $conversation->changeStatus($new_status, $user); + } + + $response['status'] = 'success'; + // Flash + $flash_message = __('Status updated'); + \Session::flash('flash_success_floating', $flash_message); + + $response['msg'] = __('Status updated'); + } + break; + + // Delete converations. + case 'bulk_delete_conversation': + // At first, check if this user is able to delete conversations + if (!auth()->user()->isAdmin() && !auth()->user()->hasPermission(\App\User::PERM_DELETE_CONVERSATIONS)) { + $response['msg'] = __('Not enough permissions'); + //\Session::flash('flash_success_floating', __('Conversations deleted')); + + return \Response::json($response); + } + + $conversations = Conversation::findMany($request->conversation_id); + $mailboxes_to_recalculate = []; + + foreach ($conversations as $conversation) { + if (!$user->can('delete', $conversation)) { + continue; + } + + if ($conversation->state != Conversation::STATE_DELETED) { + // Move to Deleted folder. + $conversation->state = Conversation::STATE_DELETED; + $conversation->user_updated_at = date('Y-m-d H:i:s'); + $conversation->updateFolder(); + $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_DELETED_TICKET; + $thread->source_via = Thread::PERSON_USER; + $thread->source_type = Thread::SOURCE_TYPE_WEB; + $thread->customer_id = $conversation->customer_id; + $thread->created_by_user_id = $user->id; + $thread->save(); + + // Remove conversation from drafts folder. + $conversation->removeFromFolder(Folder::TYPE_DRAFTS); + } else { + // Delete forever + $conversation->deleteForever(); + } + + if (!array_key_exists($conversation->mailbox_id, $mailboxes_to_recalculate)) { + $mailboxes_to_recalculate[$conversation->mailbox_id] = $conversation->mailbox; + } + } + // Recalculate folders counters for mailboxes. + foreach ($mailboxes_to_recalculate as $mailbox) { + $mailbox->updateFoldersCounters(); + } + + $response['status'] = 'success'; + \Session::flash('flash_success_floating', __('Conversations deleted')); + break; + + // Delete converations in a specific folder. + case 'empty_folder': + // At first, check if this user is able to delete conversations + if (!auth()->user()->isAdmin() && !auth()->user()->hasPermission(\App\User::PERM_DELETE_CONVERSATIONS)) { + $response['msg'] = __('Not enough permissions'); + return \Response::json($response); + } + + $response = \Eventy::filter('conversations.empty_folder', $response, + $request->mailbox_id, + $request->folder_id + ); + + if (empty($response['processed'])) { + $folder = Folder::find($request->folder_id ?? ''); + + if (!$folder) { + $response['msg'] = __('Folder not found'); + } + + if (!$response['msg']) { + $conversation_ids = Conversation::where('folder_id', $folder->id)->pluck('id')->toArray(); + Conversation::deleteConversationsForever($conversation_ids); + if ($folder->mailbox) { + Conversation::clearStarredByUserCache($user->id, $folder->mailbox_id); + $folder->mailbox->updateFoldersCounters(); + } else { + $folder->updateCounters(); + } + } + } + + $response['status'] = 'success'; + \Session::flash('flash_success_floating', __('Conversations deleted')); + break; + + // Move conversation to another mailbox. + case 'conversation_move': + $conversation = Conversation::find($request->conversation_id); + + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } + if (!$response['msg'] && !$user->can('update', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + if (!$response['msg'] && !$conversation->mailbox->userHasAccess($user->id)) { + $response['msg'] = __('Not enough permissions'); + } + + $mailbox = null; + if (!$response['msg']) { + if (!empty($request->mailbox_email)) { + $mailbox = Mailbox::where('email', $request->mailbox_email)->first(); + } else { + $mailbox = Mailbox::find($request->mailbox_id); + } + + if (!$mailbox) { + $response['msg'] = __('Mailbox not found'); + } + } + + if (!$response['msg']) { + $prev_folder_id = Conversation::getFolderParam(); + $prev_mailbox_id = $conversation->mailbox_id; + + $conversation->moveToMailbox($mailbox, $user); + + // If user does not have access to the new mailbox, + // redirect to the previous mailbox. + if (!$mailbox->userHasAccess($user->id)) { + if (!empty($prev_folder_id)) { + $response['redirect_url'] = route('mailboxes.view.folder', ['id' => $prev_mailbox_id, 'folder_id' => $prev_folder_id]); + } else { + $response['redirect_url'] = route('mailboxes.view', ['id' => $prev_mailbox_id]); + } + } + + $response['status'] = 'success'; + \Session::flash('flash_success_floating', __('Conversation moved')); + } + + break; + + // Merge conversations + case 'conversation_merge': + $conversation = Conversation::find($request->conversation_id); + + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } + if (!$response['msg'] && !$user->can('view', $conversation)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!empty($request->merge_conversation_id) && is_array($request->merge_conversation_id)) { + + $sigle_conv = count($request->merge_conversation_id) == 1; + + foreach ($request->merge_conversation_id as $merge_conversation_id) { + $merge_conversation = Conversation::find($merge_conversation_id); + + $response['msg'] = ''; + + if (!$merge_conversation) { + $response['msg'] = __('Conversation not found'); + if ($sigle_conv) { + break; + } + } + if (!$response['msg'] && !$user->can('view', $merge_conversation)) { + $response['msg'] = __('Not enough permissions').': #'.$merge_conversation->number; + if ($sigle_conv) { + break; + } + } + + if (!$response['msg']) { + $conversation->mergeConversations($merge_conversation, $user); + + if ($response['status'] != 'success') { + \Session::flash('flash_success_floating', __('Conversations merged')); + } + $response['status'] = 'success'; + } + } + } + + break; + + // Follow conversation + case 'follow': + case 'unfollow': + $conversation = Conversation::find($request->conversation_id); + + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } + if (!$response['msg'] && !$conversation->mailbox->userHasAccess($user->id)) { + $response['msg'] = __('Not enough permissions'); + } + + if ($request->action == 'follow') { + $user->followConversation($request->conversation_id); + } else { + $follower = Follower::where('conversation_id', $request->conversation_id) + ->where('user_id', $user->id) + ->first(); + if ($follower) { + $follower->delete(); + } + } + + if (!$response['msg']) { + $response['status'] = 'success'; + if ($request->action == 'follow') { + $response['msg_success'] = __('Following'); + } else { + $response['msg_success'] = __('Unfollowed'); + } + } + + break; + + case 'update_subject': + $conversation = Conversation::find($request->conversation_id); + + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } + if (!$response['msg'] && !$conversation->mailbox->userHasAccess($user->id)) { + $response['msg'] = __('Not enough permissions'); + } + + $subject = $request->value ?? ''; + $subject = trim($subject); + + if (!$response['msg'] && $subject) { + $conversation->subject = $subject; + $conversation->save(); + + $response['status'] = 'success'; + } + + break; + + case 'merge_search': + $conversation = Conversation::where(Conversation::numberFieldName(), $request->number)->first(); + + if (!$conversation) { + $response['msg'] = __('Conversation not found'); + } + if (!$response['msg'] && !$user->can('view', $conversation)) { + $response['msg'] = __('Conversation not found'); + } + + if (!$response['msg']) { + $response['html'] = \View::make('conversations/partials/merge_search_result')->with([ + 'conversation' => $conversation + ])->render(); + $response['status'] = 'success'; + } + + break; + + case 'chats_load_more': + $mailbox = Mailbox::find($request->mailbox_id); + + if (!$mailbox) { + $response['msg'] = __('Mailbox not found'); + } elseif (!$mailbox->userHasAccess($user->id)) { + $response['msg'] = __('Action not authorized'); + } + + if (!$response['msg']) { + $response['html'] = \View::make('mailboxes/partials/chat_list')->with([ + 'mailbox' => $mailbox, + 'offset' => $request->offset, + ])->render(); + $response['status'] = 'success'; + } + break; + + case 'retry_send': + $thread = Thread::find($request->thread_id); + + if (!$thread) { + $response['msg'] = __('Thread not found'); + } elseif (!$user->can('view', $thread->conversation)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + $job_id = $thread->getFailedJobId(); + + if ($job_id) { + \App\FailedJob::retry($job_id); + $thread->send_status = SendLog::STATUS_ACCEPTED; + $thread->updateSendStatusData(['msg' => '']); + $thread->save(); + + $response['status'] = 'success'; + } + } + + break; + + case 'load_customer_info': + $customer = Customer::getByEmail($request->customer_email); + + if ($customer) { + // Previous conversations + $prev_conversations = []; + + $mailbox = Mailbox::find($request->mailbox_id); + + if ($mailbox && $mailbox->userHasAccess($user->id)) { + $conversation_id = (int)$request->conversation_id ?? 0; + + $prev_conversations = $mailbox->conversations() + ->where('customer_id', $customer->id) + ->where('id', '<>', $conversation_id) + ->where('status', '!=', Conversation::STATUS_SPAM) + ->where('state', Conversation::STATE_PUBLISHED) + //->limit(self::PREV_CONVERSATIONS_LIMIT) + ->orderBy('created_at', 'desc') + ->paginate(self::PREV_CONVERSATIONS_LIMIT); + } + + $response['html'] = \View::make('conversations/partials/customer_sidebar')->with([ + 'customer' => $customer, + 'prev_conversations' => $prev_conversations, + ])->render(); + $response['status'] = 'success'; + } else { + $response['msg'] = 'Customer not found'; + } + break; + + default: + $response['msg'] = 'Unknown action'; + break; + } + + if ($response['status'] == 'error' && empty($response['msg'])) { + $response['msg'] = 'Unknown error occurred'; + } + + return \Response::json($response); + } + + /** + * Conversations ajax controller. + */ + public function ajaxHtml(Request $request) + { + switch ($request->action) { + case 'send_log': + return $this->ajaxHtmlSendLog(); + case 'show_original': + return $this->ajaxHtmlShowOriginal(); + case 'change_customer': + return $this->ajaxHtmlChangeCustomer(); + case 'move_conv': + return $this->ajaxHtmlMoveConv(); + case 'merge_conv': + return $this->ajaxHtmlMergeConv(); + case 'default_redirect': + return $this->ajaxHtmlDefaultRedirect(); + } + + abort(404); + } + + /** + * Send log. + */ + public function ajaxHtmlSendLog() + { + $thread_id = Input::get('thread_id'); + if (!$thread_id) { + abort(404); + } + + $thread = Thread::find($thread_id); + if (!$thread) { + abort(404); + } + + $user = auth()->user(); + + if (!$user->can('view', $thread->conversation)) { + abort(403); + } + + // Get send log + $log_records = SendLog::where('thread_id', $thread_id) + ->orderBy('created_at', 'desc') + ->get(); + + $customers_log = []; + $users_log = []; + foreach ($log_records as $log_record) { + if ($log_record->user_id) { + $users_log[$log_record->email][] = $log_record; + } else { + $customers_log[$log_record->email][] = $log_record; + } + } + + return view('conversations/ajax_html/send_log', [ + 'customers_log' => $customers_log, + 'users_log' => $users_log, + ]); + } + + /** + * Show original message headers. + */ + public function ajaxHtmlShowOriginal() + { + $thread_id = Input::get('thread_id'); + if (!$thread_id) { + abort(404); + } + + $thread = Thread::find($thread_id); + if (!$thread) { + abort(404); + } + + $user = auth()->user(); + + if (!$user->can('view', $thread->conversation)) { + abort(403); + } + + $fetched = true; + $body_preview = $thread->body; + + if ($thread->isCustomerMessage()) { + $fetched = false; + + // Try to fetch original body by imap. + $body_imap = $thread->fetchBody(); + if ($body_imap) { + $fetched = true; + $body_preview = $body_imap; + } + } + + return view('conversations/ajax_html/show_original', [ + 'thread' => $thread, + 'body_preview' => $body_preview, + 'fetched' => $fetched, + ]); + } + + /** + * Change conversation customer. + */ + public function ajaxHtmlChangeCustomer() + { + $conversation_id = Input::get('conversation_id'); + if (!$conversation_id) { + abort(404); + } + + $conversation = Conversation::find($conversation_id); + if (!$conversation) { + abort(404); + } + + $user = auth()->user(); + + if (!$user->can('view', $conversation)) { + abort(403); + } + + return view('conversations/ajax_html/change_customer', [ + 'conversation' => $conversation, + ]); + } + + /** + * Move conversation to other mailbox. + */ + public function ajaxHtmlMoveConv() + { + $conversation_id = Input::get('conversation_id'); + if (!$conversation_id) { + abort(404); + } + + $conversation = Conversation::find($conversation_id); + if (!$conversation) { + abort(404); + } + + $user = auth()->user(); + + if (!$user->can('view', $conversation)) { + abort(403); + } + + return view('conversations/ajax_html/move_conv', [ + 'conversation' => $conversation, + 'mailboxes' => $user->mailboxesCanView(), + ]); + } + + /** + * Merge conversations. + */ + public function ajaxHtmlMergeConv() + { + $conversation_id = Input::get('conversation_id'); + if (!$conversation_id) { + abort(404); + } + + $conversation = Conversation::find($conversation_id); + if (!$conversation) { + abort(404); + } + + $user = auth()->user(); + + if (!$user->can('view', $conversation)) { + \Helper::denyAccess(); + } + + $prev_conversations = []; + + if ($conversation->customer_id) { + $prev_conversations = $conversation->mailbox->conversations() + ->where('customer_id', $conversation->customer_id) + ->where('id', '<>', $conversation->id) + ->where('status', '!=', Conversation::STATUS_SPAM) + ->where('state', Conversation::STATE_PUBLISHED) + ->orderBy('created_at', 'desc') + ->paginate(500); + } + + return view('conversations/ajax_html/merge_conv', [ + 'conversation' => $conversation, + 'prev_conversations' => $prev_conversations, + + ]); + } + + /** + * Change default redirect for the mailbox. + */ + public function ajaxHtmlDefaultRedirect() + { + $mailbox_id = Input::get('mailbox_id'); + if (!$mailbox_id) { + abort(404); + } + + $mailbox = Mailbox::find($mailbox_id); + if (!$mailbox) { + abort(404); + } + + $user = auth()->user(); + + if (!$user->can('view', $mailbox)) { + abort(403); + } + + return view('conversations/ajax_html/default_redirect', [ + 'after_send' => $user->mailboxSettings($mailbox_id)->after_send, + 'mailbox_id' => $mailbox_id, + ]); + } + + /** + * Get redirect URL after performing an action. + */ + public function getRedirectUrl($request, $conversation, $user) + { + if (!empty($request->after_send)) { + $after_send = $request->after_send; + } else { + // todo: use $user->mailboxSettings() + $after_send = $conversation->mailbox->getUserSettings($user->id)->after_send; + } + + // When creating a new conversation. + if (!empty($request->is_create) && $after_send != MailboxUser::AFTER_SEND_STAY) { + return route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $conversation->folder_id]); + } + // if ($conversation->state == Conversation::STATE_DRAFT) { + // return route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $conversation->folder_id]); + // } + + if (!empty($after_send)) { + switch ($after_send) { + case MailboxUser::AFTER_SEND_STAY: + default: + $redirect_url = $conversation->url(); + break; + case MailboxUser::AFTER_SEND_FOLDER: + $folder_id = Conversation::getFolderParam(); + if (!$folder_id) { + $folder_id = $conversation->folder_id; + } + $redirect_url = route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $folder_id]); + break; + case MailboxUser::AFTER_SEND_NEXT: + // We need to get not any next conversation, but ACTIVE next conversation. + $redirect_url = $conversation->urlNext(Conversation::getFolderParam(), Conversation::STATUS_ACTIVE, true); + break; + } + } else { + // If something went wrong and after_send not set, just show the reply + $redirect_url = $conversation->url(); + } + + return $redirect_url; + } + + /** + * Upload files and images. + */ + public function upload(Request $request) + { + $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']) { + $embedded = true; + + if (!empty($request->attach) && (int) $request->attach) { + $embedded = false; + } + + $attachment = Attachment::create( + $request->file->getClientOriginalName(), + $request->file->getMimeType(), + null, + '', + $request->file, + $embedded, + null, + $user->id + ); + + if ($attachment) { + $response['status'] = 'success'; + $response['url'] = $attachment->url(); + $response['attachment_id'] = $attachment->id; + } else { + $response['msg'] = __('Error occurred uploading file'); + } + } + + return \Response::json($response); + } + + /** + * Ajax conversation navigation. + */ + public function ajaxConversationsPagination(Request $request, $response, $user) + { + //$mailbox = Mailbox::find($request->mailbox_id); + $folder = null; + $conversations = []; + + if (!$response['msg']) { + $folder = \Eventy::filter('conversations.ajax_pagination_folder', Folder::find($request->folder_id), $request, $response, $user); + if (!$folder) { + $response['msg'] = __('Folder not found'); + } + } + if (!$response['msg'] && !$user->can('view', $folder)) { + $response['msg'] = __('Not enough permissions'); + } + + // We should not use mailbox_id from the request, as it can be changed. + if (!$response['msg'] && !$user->can('view', $folder->mailbox)) { + $response['msg'] = __('Not enough permissions'); + } + + if (!$response['msg']) { + $query_conversations = Conversation::getQueryByFolder($folder, $user->id); + $conversations = $folder->queryAddOrderBy($query_conversations)->paginate(Conversation::DEFAULT_LIST_SIZE, ['*'], 'page', $request->page); + } + + $response['status'] = 'success'; + + $response['html'] = view('conversations/conversations_table', [ + 'folder' => $folder, + 'conversations' => $conversations, + ])->render(); + + return $response; + } + + /** + * Search. + */ + public function search(Request $request) + { + $user = auth()->user(); + $conversations = []; + $customers = []; + + $mode = $this->getSearchMode($request); + + // Search query + $q = $this->getSearchQuery($request); + + // Filters. + $filters = $this->getSearchFilters($request); + $filters_data = []; + // Modify filters is needed. + if (!empty($filters['customer'])) { + // Get customer name. + $filters_data['customer'] = Customer::find($filters['customer']); + } + //$filters = \Eventy::filter('search.filters', $filters, $filters_data, $mode, $q); + + // Remember recent query. + $recent_search_queries = session('recent_search_queries') ?? []; + if ($q && !in_array($q, $recent_search_queries)) { + array_unshift($recent_search_queries, $q); + $recent_search_queries = array_slice($recent_search_queries, 0, 4); + session()->put('recent_search_queries', $recent_search_queries); + } + + $conversations = []; + if (\Eventy::filter('search.is_needed', true, 'conversations')) { + $conversations = $this->searchQuery($user, $q, $filters); + } + + // Jump to the conversation if searching by conversation number. + if (count($conversations) == 1 + && $conversations[0]->number == $q + && empty($filters) + && !$request->x_embed + ) { + return redirect()->away($conversations[0]->url($conversations[0]->folder_id)); + } + + $customers = $this->searchCustomers($request, $user); + + // Dummy folder + $folder = $this->getSearchFolder($conversations); + + // List of available filters. + if ($mode == Conversation::SEARCH_MODE_CONV) { + $filters_list = \Eventy::filter('search.filters_list', Conversation::$search_filters, $mode, $filters, $q); + } else { + $filters_list = \Eventy::filter('search.filters_list_customers', Customer::$search_filters, $mode, $filters, $q); + } + + $mailboxes = \Cache::remember('search_filter_mailboxes_'.$user->id, 5, function () use ($user) { + return $user->mailboxesCanView(); + }); + $users = \Cache::remember('search_filter_users_'.$user->id, 5, function () use ($user, $mailboxes) { + return \Eventy::filter('search.assignees', $user->whichUsersCanView($mailboxes), $user, $mailboxes); + }); + $search_mailbox = null; + if (isset($filters['mailbox'])) { + $mailbox_id = (int)$filters['mailbox']; + if ($mailbox_id && in_array($mailbox_id, $mailboxes->pluck('id')->toArray())) { + foreach ($mailboxes as $mailbox_item) { + if ($mailbox_item->id == $mailbox_id) { + $search_mailbox = $mailbox_item; + break; + } + } + } + } elseif (count($mailboxes) == 1) { + $search_mailbox = $mailboxes[0]; + } + + return view('conversations/search', [ + 'folder' => $folder, + 'q' => $request->q, + 'filters' => $filters, + 'filters_list' => $filters_list, + 'filters_data' => $filters_data, + //'filters_list_all' => $filters_list_all, + 'mode' => $mode, + 'conversations' => $conversations, + 'customers' => $customers, + 'recent' => session('recent_search_queries'), + 'users' => $users, + 'mailboxes' => $mailboxes, + 'search_mailbox' => $search_mailbox, + ]); + } + + /** + * Search conversations. + */ + public function getSearchMode($request) + { + $mode = Conversation::SEARCH_MODE_CONV; + if (!empty($request->mode) && $request->mode == Conversation::SEARCH_MODE_CUSTOMERS) { + $mode = Conversation::SEARCH_MODE_CUSTOMERS; + } + return $mode; + } + + /** + * Search conversations. + */ + public function searchQuery($user, $q, $filters) + { + $conversations = \Eventy::filter('search.conversations.perform', '', $q, $filters, $user); + if ($conversations !== '') { + return $conversations; + } + $query_conversations = Conversation::search($q, $filters, $user); + return $query_conversations->paginate(Conversation::DEFAULT_LIST_SIZE); + } + + /** + * Get and format search query. + */ + public function getSearchQuery($request) + { + $q = ''; + if (!empty($request->q)) { + $q = $request->q; + } elseif (!empty($request->filter) && !empty($request->filter['q'])) { + $q = $request->filter['q']; + } + + return trim($q); + } + + /** + * Get and format search filters. + */ + public function getSearchFilters($request) + { + $filters = []; + + if (!empty($request->f)) { + $filters = $request->f; + } elseif (!empty($request->filter) && !empty($request->filter['f'])) { + $filters = $request->filter['f']; + } + + foreach ($filters as $filter => $value) { + switch ($filter) { + case 'after': + case 'before': + if ($value) { + $filters[$filter] = date('Y-m-d', strtotime($value)); + } + break; + } + } + + $filters = \Eventy::filter('search.filters', $filters, $this->getSearchMode($request), $request); + + return $filters; + } + + /** + * Search conversations. + */ + public function searchCustomers($request, $user) + { + // Get IDs of mailboxes to which user has access + $mailbox_ids = $user->mailboxesIdsCanView(); + + // Filters + $filters = $this->getSearchFilters($request);; + + // Search query + $q = $this->getSearchQuery($request); + + // Like is case insensitive. + $like = '%'.mb_strtolower($q).'%'; + + // We need to use aggregate function for email to avoid "Grouping error" error in PostgreSQL. + $query_customers = Customer::select(['customers.*', \DB::raw('MAX(emails.email)')]) + ->groupby('customers.id') + ->leftJoin('emails', function ($join) { + $join->on('customers.id', '=', 'emails.customer_id'); + }) + ->where(function ($query) use ($like, $q) { + $like_op = 'like'; + if (\Helper::isPgSql()) { + $like_op = 'ilike'; + } + + $query->where('customers.first_name', $like_op, $like) + ->orWhere('customers.last_name', $like_op, $like) + ->orWhere('customers.company', $like_op, $like) + ->orWhere('customers.job_title', $like_op, $like) + ->orWhere('customers.websites', $like_op, $like) + ->orWhere('customers.social_profiles', $like_op, $like) + ->orWhere('customers.address', $like_op, $like) + ->orWhere('customers.city', $like_op, $like) + ->orWhere('customers.state', $like_op, $like) + ->orWhere('customers.zip', $like_op, $like) + ->orWhere('emails.email', $like_op, $like); + + $phone_numeric = \Helper::phoneToNumeric($q); + + if ($phone_numeric) { + $query->orWhere('customers.phones', $like_op, '%"'.$phone_numeric.'"%'); + } + }); + + if (!empty($filters['mailbox']) && in_array($filters['mailbox'], $mailbox_ids)) { + $query_customers->join('conversations', function ($join) use ($filters) { + $join->on('conversations.customer_id', '=', 'customers.id'); + //$join->on('conversations.mailbox_id', '=', $filters['mailbox']); + }); + $query_customers->where('conversations.mailbox_id', '=', $filters['mailbox']); + } + + $query_customers = \Eventy::filter('search.customers.apply_filters', $query_customers, $filters, $q); + + return $query_customers->paginate(50); + } + + /** + * Get dummy folder for search. + */ + public function getSearchFolder($conversations) + { + $folder = new Folder(); + $folder->type = Folder::TYPE_ASSIGNED; + // todo: use select([\DB::raw('SQL_CALC_FOUND_ROWS *')]) to count records + //$folder->total_count = $conversations->count(); + + return $folder; + } + + /** + * Ajax conversations search. + */ + public function ajaxConversationsFilter(Request $request, $response, $user) + { + if (array_key_exists('q', $request->filter)) { + // Search. + $conversations = $this->searchQuery($user, $this->getSearchQuery($request), $this->getSearchFilters($request)); + } else { + // Filters in the mailbox or customer profile. + $conversations = $this->conversationsFilterQuery($request, $user); + } + + $response['status'] = 'success'; + + $response['html'] = view('conversations/conversations_table', [ + 'conversations' => $conversations, + 'params' => $request->params ?? [], + ])->render(); + + return $response; + } + + /** + * Filter conversations according to the request. + */ + public function conversationsFilterQuery($request, $user) + { + // Get IDs of mailboxes to which user has access + $mailbox_ids = $user->mailboxesIdsCanView(); + + $query_conversations = Conversation::whereIn('conversations.mailbox_id', $mailbox_ids) + ->orderBy('conversations.last_reply_at'); + + foreach ($request->filter as $field => $value) { + switch ($field) { + case 'customer_id': + $query_conversations->where('customer_id', $value); + break; + } + } + + return $query_conversations->paginate(Conversation::DEFAULT_LIST_SIZE); + } + + /** + * Process attachments on reply, new conversation, saving draft and forwarding. + */ + public function processReplyAttachments($request) + { + $has_attachments = false; + $attachments = []; + if (!empty($request->attachments_all)) { + $embeds = []; + if (!empty($request->attachments)) { + $attachments = $request->attachments; + } + if (!empty($request->embeds)) { + $embeds = $request->embeds; + } + if (count($attachments) != count($embeds)) { + $has_attachments = true; + } + $attachments_to_remove = array_diff($request->attachments_all, $attachments); + $attachments_to_remove = array_diff($attachments_to_remove, $embeds); + Attachment::deleteByIds($attachments_to_remove); + } + + return [ + 'has_attachments' => $has_attachments, + 'attachments' => $attachments, + ]; + } + + /** + * Undo reply. + */ + public function undoReply(Request $request, $thread_id) + { + $thread = Thread::findOrFail($thread_id); + + if (!$thread) { + abort(404); + } + + $conversation = $thread->conversation; + $this->authorize('view', $conversation); + + // Check undo timeout + if ($thread->created_at->diffInSeconds(now()) > Conversation::UNDO_TIMOUT) { + \Session::flash('flash_error_floating', __('Sending can not be undone')); + + return redirect()->away($conversation->url($conversation->folder_id)); + } + + // Convert reply into draft + $thread->state = Thread::STATE_DRAFT; + $thread->save(); + + // https://github.com/freescout-helpdesk/freescout/issues/3300 + // Cancel all SendReplyToCustomer jobs for this thread. + $jobs_to_cancel = \App\Job::where('queue', 'emails') + ->where('payload', 'like', '{"displayName":"App\\\\\\\\Jobs\\\\\\\\SendReplyToCustomer"%') + ->get(); + + foreach ($jobs_to_cancel as $job) { + $job_thread = $job->getCommandLastThread(); + if ($job_thread && $job_thread->id == $thread->id) { + $job->delete(); + } + } + + // Get penultimate reply + $last_thread = $conversation->threads() + ->where('id', '<>', $thread->id) + ->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE]) + ->orderBy('created_at', 'desc') + ->first(); + + $folder_id = $conversation->folder_id; + + // Restore conversation data from penultimate thread + if ($last_thread) { + $conversation->setCc($last_thread->cc); + $conversation->setBcc($last_thread->bcc); + $conversation->last_reply_at = $last_thread->created_at; + $conversation->last_reply_from = $last_thread->source_via; + $conversation->user_updated_at = date('Y-m-d H:i:s'); + } + if ($thread->first) { + // This was a new conversation, move it to drafts + $conversation->state = Thread::STATE_DRAFT; + $conversation->updateFolder(); + $conversation->mailbox->updateFoldersCounters(); + $folder_id = null; + } + $conversation->save(); + + // If forwarding has been undone, we need to remove newly created conversation. + // No need to remove notifications, as they won't work if conversation does not exist. + if ($thread->isForward()) { + $forwarded_conversation = $thread->getForwardChildConversation(); + if ($forwarded_conversation) { + $forwarded_conversation->threads()->delete(); + // todo: maybe perform soft delete of the conversation. + $forwarded_conversation->delete(); + } + } + + Conversation::updatePreview($conversation->id); + + return redirect()->away($conversation->url($folder_id, null, ['show_draft' => $thread->id])); + } + + /** + * Find or create customer when creating a Phone conversation. + */ + public function processPhoneCustomer($request) + { + $customer_data = []; + $customer_email = ''; + $customer = null; + + // Check to prevent creating empty customers. + $request_name = ''; + $request_phone = ''; + if (trim($request->name ?? '') || trim($request->phone ?? '')) { + $request_name = trim($request->name ?? ''); + $request_phone = trim($request->phone ?? ''); + + $name_parts = explode(' ', $request_name); + $customer_data['first_name'] = $name_parts[0]; + if (!empty($name_parts[1])) { + $customer_data['last_name'] = $name_parts[1]; + } + $customer_data['phones'] = [$request_phone]; + } + + // Check if name field contains ID of the customer. + if (!$request->customer_id && is_numeric($request_name)) { + // Try to find customer by ID. + $customer = Customer::find($request_name); + } + + if (!$customer && $request->to_email) { + // Try to get customer by email. + $customer = Customer::getByEmail($request->to_email); + if ($customer) { + $customer_email = $request->to_email; + } + } + + // Try to find customer by phone. + if (!$customer && $request_phone) { + $customer = Customer::findByPhone($request_phone); + if ($customer) { + $customer_email = $customer->getMainEmail(); + } + } + + if (!$customer) { + // Create customer with passed name, email and phone + if (Email::sanitizeEmail($request->to_email)) { + $customer_email = $request->to_email; + // If new email entered, attach email to the current customer + // instead of creating a new customer + if ($request->customer_id) { + $customer = Customer::find($request->customer_id); + if ($customer) { + // Add email to customer. + $customer->addEmail($customer_email, true); + } else { + $customer = Customer::create($customer_email, $customer_data); + } + } else { + $customer = Customer::create($customer_email, $customer_data); + } + } elseif ($customer_data) { + if ($request->customer_id) { + $customer = Customer::find($request->customer_id); + if ($customer) { + $customer->setData($customer_data, false, true); + } + } + + if (!$customer) { + $customer = Customer::createWithoutEmail($customer_data); + } + } + } else { + $customer->setData($customer_data, false, true); + // Add email to customer. + if (Email::sanitizeEmail($request->to_email)) { + $customer->addEmail($request->to_email, true); + } + } + + return [ + 'customer' => $customer, + 'customer_email' => $customer_email, + ]; + } + + /** + * View conversation. + */ + public function chats(Request $request, $mailbox_id) + { + $user = auth()->user(); + + $mailbox = Mailbox::findOrFailWithSettings($mailbox_id, $user->id); + $this->authorize('viewCached', $mailbox); + + // Redirect to the first available chat. + $chats = Conversation::getChats($mailbox_id, 0, 1); + + if (count($chats)) { + if (!\Helper::isChatMode()) { + \Helper::setChatMode(true); + } + + return redirect()->away($chats[0]->url()); + } + + return view('conversations/chats', [ + 'is_in_chat_mode' => true, + 'mailbox' => $mailbox, + ]); + } +} diff --git a/freescout-dist/app/Http/Controllers/CustomersController.php b/freescout-dist/app/Http/Controllers/CustomersController.php new file mode 100644 index 0000000..8e8b247 --- /dev/null +++ b/freescout-dist/app/Http/Controllers/CustomersController.php @@ -0,0 +1,414 @@ +middleware('auth'); + } + + /** + * Edit customer. + */ + public function update($id) + { + $customer = Customer::findOrFail($id); + + $customer_emails = $customer->emails; + if (count($customer_emails)) { + foreach ($customer_emails as $row) { + $emails[] = $row->email; + } + } else { + $emails = ['']; + } + + return view('customers/update', ['customer' => $customer, 'emails' => $emails]); + } + + /** + * Save customer. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\Response + */ + public function updateSave($id, Request $request) + { + function mb_ucfirst($string) + { + return mb_strtoupper(mb_substr($string, 0, 1)).mb_strtolower(mb_substr($string, 1)); + } + + $customer = Customer::findOrFail($id); + $flash_message = ''; + + // First name or email must be specified + $validator = Validator::make($request->all(), [ + 'first_name' => 'nullable|string|max:255|required_without:emails.0', + 'last_name' => 'nullable|string|max:255', + 'city' => 'nullable|string|max:255', + 'state' => 'nullable|string|max:255', + 'zip' => 'nullable|string|max:12', + 'country' => 'nullable|string|max:2', + //'emails' => 'array|required_without:first_name', + //'emails.1' => 'nullable|email|required_without:first_name', + 'emails.*' => 'nullable|email|distinct|required_without:first_name', + 'photo_url' => 'nullable|image|mimes:jpeg,png,jpg,gif', + ]); + $validator->setAttributeNames([ + 'photo_url' => __('Photo'), + 'emails.*' => __('Email'), + ]); + + // Photo + $validator->after(function ($validator) use ($customer, $request) { + if ($request->hasFile('photo_url')) { + $path_url = $customer->savePhoto($request->file('photo_url')->getRealPath(), $request->file('photo_url')->getMimeType()); + + if ($path_url) { + $customer->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('customers.update', ['id' => $id]) + ->withErrors($validator) + ->withInput(); + } + + $new_emails = []; + $new_emails_change_customer = []; + $removed_emails = []; + + // Detect new emails added + $customer_emails = $customer->emails()->pluck('email')->toArray(); + foreach ($request->emails as $email) { + if (!in_array($email, $customer_emails)) { + $new_emails[] = $email; + } + } + + // If new email belongs to another customer, let user know about it in the flash message + foreach ($new_emails as $new_email) { + $email = Email::where('email', $new_email)->first(); + if ($email && $email->customer) { + // If customer whose email is removed does not have first name and other emails + // we have to create first name for this customer + if (!$email->customer->first_name && count($email->customer->emails) == 1) { + if ($request->first_name) { + $email->customer->first_name = $request->first_name; + } elseif ($customer->first_name) { + $email->customer->first_name = $customer->first_name; + } else { + $email->customer->first_name = mb_ucfirst($email->getNameFromEmail()); + } + $email->customer->save(); + } + + $flash_message .= __('Email :tag_email_begin:email:tag_email_end has been moved from another customer: :a_begin:customer:a_end.', [ + 'email' => $email->email, + 'tag_email_begin' => '', + 'tag_email_end' => '', + 'customer' => $email->customer->getFullName(), + 'a_begin' => '', + 'a_end' => '', + ]).' '; + + $new_emails_change_customer[] = $email; + } + } + + // Detect removed emails + foreach ($customer_emails as $email) { + if (!in_array($email, $request->emails)) { + $removed_emails[] = $email; + } + } + + $request_data = $request->all(); + + if (isset($request_data['photo_url'])) { + unset($request_data['photo_url']); + } + + $customer->setData($request_data); + // Websites + // if (!empty($request->websites)) { + // $customer->setWebsites($request->websites); + // } + $customer->save(); + + $customer->syncEmails($request->emails); + + // Update customer_id in all conversations added to the current customer. + foreach ($new_emails_change_customer as $new_email) { + if ($new_email->customer_id) { + $conversations_to_change_customer = Conversation::where('customer_id', $new_email->customer_id)->get(); + } else { + // This does not work for phone conversations. + $conversations_to_change_customer = Conversation::where('customer_email', $new_email->email)->get(); + } + foreach ($conversations_to_change_customer as $conversation) { + // We have to pass user to create line item and let others know that customer has changed. + // Conversation may be even in other mailbox where user does not have an access. + $conversation->changeCustomer($new_email->email, $customer, auth()->user()); + } + } + + // Update customer in conversations for emails removed from current customer. + foreach ($removed_emails as $removed_email) { + $email = Email::where('email', $removed_email)->first(); + if ($email) { + $conversations = Conversation::where('customer_email', $email->email)->get(); + foreach ($conversations as $conversation) { + $conversation->changeCustomer($email->email, $email->customer, auth()->user()); + } + } + } + + \Eventy::action('customer.updated', $customer); + + $flash_message = __('Customer saved successfully.').' '.$flash_message; + \Session::flash('flash_success_unescaped', $flash_message); + + \Session::flash('customer.updated', 1); + + return redirect()->route('customers.update', ['id' => $id]); + } + + /** + * User mailboxes. + */ + public function permissions($id) + { + $user = User::findOrFail($id); + $this->authorize('update', $user); + + $mailboxes = Mailbox::all(); + + return view('users/permissions', ['user' => $user, 'mailboxes' => $mailboxes, 'user_mailboxes' => $user->mailboxes]); + } + + /** + * Save user permissions. + * + * @param int $id + * @param \Illuminate\Http\Request $request + */ + public function permissionsSave($id, Request $request) + { + $user = User::findOrFail($id); + $this->authorize('update', $user); + + $user->mailboxes()->sync($request->mailboxes ?: []); + + \Session::flash('flash_success', __('Permissions saved successfully')); + + return redirect()->route('users.permissions', ['id' => $id]); + } + + /** + * View customer conversations. + * + * @param intg $id + */ + public function conversations($id) + { + $customer = Customer::findOrFail($id); + + $conversations = $customer->conversations() + ->where('customer_id', $customer->id) + ->whereIn('mailbox_id', auth()->user()->mailboxesIdsCanView()) + ->orderBy('created_at', 'desc') + ->paginate(Conversation::DEFAULT_LIST_SIZE); + + return view('customers/conversations', [ + 'customer' => $customer, + 'conversations' => $conversations, + ]); + } + + /** + * Customers ajax search. + */ + public function ajaxSearch(Request $request) + { + $response = [ + 'results' => [], + 'pagination' => ['more' => false], + ]; + + $q = $request->q; + + $join_emails = false; + if ($request->search_by == 'all' || $request->search_by == 'email' || $request->exclude_email) { + $join_emails = true; + } + + $select_list = ['customers.id', 'first_name', 'last_name']; + if ($join_emails) { + $select_list[] = 'emails.email'; + } + if ($request->show_fields == 'phone') { + $select_list[] = 'phones'; + } + $customers_query = Customer::select($select_list); + + if ($join_emails) { + if ($request->allow_non_emails) { + $customers_query->leftJoin('emails', 'customers.id', '=', 'emails.customer_id'); + } else { + $customers_query->join('emails', 'customers.id', '=', 'emails.customer_id'); + } + } + + if ($request->search_by == 'all' || $request->search_by == 'email') { + $customers_query->where('emails.email', 'like', '%'.$q.'%'); + } + if ($request->exclude_email) { + $customers_query->where('emails.email', '<>', $request->exclude_email); + } + if ($request->search_by == 'all' || $request->search_by == 'name') { + $customers_query->orWhere('first_name', 'like', '%'.$q.'%') + ->orWhere('last_name', 'like', '%'.$q.'%'); + } + if ($request->search_by == 'phone') { + $customers_query->where('customers.phones', 'like', '%'.$q.'%'); + } + + $customers = $customers_query->paginate(20); + + foreach ($customers as $customer) { + $id = ''; + $text = ''; + + if ($request->show_fields != 'all') { + switch ($request->show_fields) { + case 'email': + $text = $customer->email; + break; + case 'name': + $text = $customer->getFullName(); + break; + case 'phone': + // Get phone which matches + $phones = $customer->getPhones(); + foreach ($phones as $phone) { + if (strstr($phone['value'], $q)) { + $text = $phone['value']; + if ($customer->getFullName()) { + $text .= ' — '.$customer->getFullName(); + } + $id = $phone['value']; + break; + } + } + break; + default: + $text = $customer->getNameAndEmail(); + break; + } + } else { + $text = $customer->getNameAndEmail(); + } + + if (!$id) { + if (!empty($request->use_id)) { + $id = $customer->id; + } else { + $id = $customer->email; + } + } + $response['results'][] = [ + 'id' => $id, + 'text' => $text, + ]; + } + + $response['pagination']['more'] = $customers->hasMorePages(); + + return \Response::json($response); + } + + /** + * Ajax controller. + */ + public function ajax(Request $request) + { + $response = [ + 'status' => 'error', + 'msg' => '', // this is error message + ]; + + $user = auth()->user(); + + switch ($request->action) { + + // Change conversation user + case 'create': + // First name or email must be specified + $validator = Validator::make($request->all(), [ + 'first_name' => 'required|string|max:255', + 'last_name' => 'nullable|string|max:255', + 'email' => 'required|email|unique:emails,email', + ]); + + if ($validator->fails()) { + foreach ($validator->errors()->getMessages()as $errors) { + foreach ($errors as $field => $message) { + $response['msg'] .= $message.' '; + } + } + } + + if (!$response['msg']) { + + $customer = Customer::create($request->email, $request->all()); + if ($customer) { + $response['email'] = $request->email; + $response['status'] = 'success'; + } + } + break; + + // Conversations navigation + case 'customers_pagination': + + $customers = app('App\Http\Controllers\ConversationsController')->searchCustomers($request, $user); + + $response['status'] = 'success'; + + $response['html'] = view('customers/partials/customers_table', [ + 'customers' => $customers, + ])->render(); + 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/MailboxesController.php b/freescout-dist/app/Http/Controllers/MailboxesController.php new file mode 100755 index 0000000..c85cc9b --- /dev/null +++ b/freescout-dist/app/Http/Controllers/MailboxesController.php @@ -0,0 +1,918 @@ +middleware('auth'); + } + + /** + * Mailboxes list. + */ + public function mailboxes() + { + $user = auth()->user(); + + $mailboxes = $user->mailboxesCanView(); + + if (!\Eventy::filter('user.can_view_mailbox_menu', false, $user)) { + foreach ($mailboxes as $i => $mailbox) { + if (!$user->canManageMailbox($mailbox->id)) { + $mailboxes->forget($i); + } + } + } + + return view('mailboxes/mailboxes', ['mailboxes' => $mailboxes]); + } + + /** + * New mailbox. + */ + public function create() + { + $this->authorize('create', 'App\Mailbox'); + + $users = User::nonDeleted()->where('role', '!=', User::ROLE_ADMIN)->get(); + + return view('mailboxes/create', ['users' => $users]); + } + + /** + * Create new mailbox. + * + * @param \Illuminate\Http\Request $request + */ + public function createSave(Request $request) + { + $invalid = false; + + $this->authorize('create', 'App\Mailbox'); + + $validator = Validator::make($request->all(), [ + 'email' => 'required|string|email|max:128|unique:mailboxes', + 'name' => 'required|string|max:40', + ]); + + // //event(new Registered($user = $this->create($request->all()))); + + if (Mailbox::userEmailExists($request->email)) { + $invalid = true; + $validator->errors()->add('email', __('There is a user with such email. Users and mailboxes can not have the same email addresses.')); + } + + if ($invalid || $validator->fails()) { + return redirect()->route('mailboxes.create') + ->withErrors($validator) + ->withInput(); + } + + $mailbox = new Mailbox(); + $mailbox->fill($request->all()); + + $mailbox->save(); + + $mailbox->users()->sync($request->users ?: []); + $mailbox->syncPersonalFolders($request->users); + + \Session::flash('flash_success_floating', __('Mailbox created successfully')); + + return redirect()->route('mailboxes.update', ['id' => $mailbox->id]); + } + + /** + * Edit mailbox. + */ + public function update($id) + { + $mailbox = Mailbox::findOrFail($id); + $user = auth()->user(); + if (!$user->can('updateSettings', $mailbox) && !$user->can('updateEmailSignature', $mailbox)) { + $accessible_route = ''; + + $mailbox_settings = $user->mailboxSettings($mailbox->id); + + if (!is_array($mailbox_settings->access)) { + $access_permissions = json_decode($mailbox_settings->access ?? ''); + } else { + $access_permissions = $mailbox_settings->access; + } + + if ($access_permissions && is_array($access_permissions)) { + foreach ($access_permissions as $perm) { + $accessible_route = Mailbox::getAccessPermissionRoute($perm); + if ($accessible_route) { + break; + } + } + } + if (!$accessible_route) { + $accessible_route = \Eventy::filter('mailbox.accessible_settings_route', '', auth()->user(), $mailbox); + } + if ($accessible_route) { + return redirect()->route($accessible_route, ['id' => $mailbox->id]); + } else { + \Helper::denyAccess(); + } + } + + $user = auth()->user(); + $mailbox_user = $user->mailboxesWithSettings()->where('mailbox_id', $id)->first(); + if (!$mailbox_user && $user->isAdmin()) { + // Admin may not be connected to the mailbox yet + $user->mailboxes()->attach($id); + $mailbox_user = $user->mailboxesWithSettings()->where('mailbox_id', $id)->first(); + } + + //$mailboxes = Mailbox::all()->except($id); + + return view('mailboxes/update', ['mailbox' => $mailbox, 'mailbox_user' => $mailbox_user, 'flashes' => $this->mailboxActiveWarning($mailbox)]); + } + + /** + * Save mailbox. + * + * @param int $id + * @param \Illuminate\Http\Request $request + */ + public function updateSave($id, Request $request) + { + $invalid = false; + $mailbox = Mailbox::findOrFail($id); + + $user = auth()->user(); + + if (!$user->can('updateSettings', $mailbox) && !$user->can('updateEmailSignature', $mailbox)) { + \Helper::denyAccess(); + } + + if ($user->can('updateSettings', $mailbox)) { + + // Checkboxes + $request->merge([ + 'aliases_reply' => ($request->filled('aliases_reply') ?? false), + ]); + + // if not admin, the text only fields don't pass so spike them into the request. + if (!auth()->user()->isAdmin()) { + $request->merge([ + 'name' => $mailbox->name, + 'email' => $mailbox->email + ]); + } + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:40', + 'email' => 'required|string|email|max:128|unique:mailboxes,email,'.$id, + 'aliases' => 'nullable|string|max:255', + 'from_name' => 'required|integer', + 'from_name_custom' => 'nullable|string|max:128', + 'ticket_status' => 'required|integer', + 'template' => 'required|integer', + 'ticket_assignee' => 'required|integer', + ]); + + //event(new Registered($user = $this->create($request->all()))); + if (Mailbox::userEmailExists($request->email)) { + $invalid = true; + $validator->errors()->add('email', __('There is a user with such email. Users and mailboxes can not have the same email addresses.')); + } + + $validator = \Eventy::filter('mailbox.settings_validator', $validator, $mailbox, $request); + + if ($invalid || count($validator->errors()) || $validator->fails()) { + return redirect()->route('mailboxes.update', ['id' => $id]) + ->withErrors($validator) + ->withInput(); + } + } + + if ($user->can('updateEmailSignature', $mailbox)) { + $validator = Validator::make($request->all(), [ + 'signature' => 'nullable|string', + ]); + + if ($validator->fails()) { + return redirect()->route('mailboxes.email_signature', ['id' => $id]) + ->withErrors($validator) + ->withInput(); + } + } + + \Eventy::action('mailbox.settings_before_save', $mailbox, $request); + + $mailbox->fill($request->all()); + + $mailbox->save(); + + \Session::flash('flash_success_floating', __('Mailbox settings saved')); + + return redirect()->route('mailboxes.update', ['id' => $id]); + } + + /** + * Mailbox permissions. + */ + public function permissions($id) + { + $mailbox = Mailbox::findOrFail($id); + + $this->authorize('updatePermissions', $mailbox); + + $users = User::nonDeleted()->where('role', '!=', User::ROLE_ADMIN)->get(); + $users = User::sortUsers($users); + + $managers = User::nonDeleted() + ->select(['users.*', 'mailbox_user.hide', 'mailbox_user.access']) + ->leftJoin('mailbox_user', function ($join) use ($mailbox) { + $join->on('mailbox_user.user_id', '=', 'users.id'); + $join->where('mailbox_user.mailbox_id', $mailbox->id); + })->get(); + $managers = User::sortUsers($managers); + + return view('mailboxes/permissions', [ + 'mailbox' => $mailbox, + 'users' => $users, + 'managers' => $managers, + 'mailbox_users' => $mailbox->users, + ]); + } + + /** + * Save mailbox permissions. + * + * @param int $id + * @param \Illuminate\Http\Request $request + */ + public function permissionsSave($id, Request $request) + { + $mailbox = Mailbox::findOrFail($id); + $this->authorize('updatePermissions', $mailbox); + + $user = auth()->user(); + + $mailbox->users()->sync(\Eventy::filter('mailbox.permission_users', $request->users, $id) ?: []); + $mailbox->syncPersonalFolders($request->users); + + // Save admins settings. + $admins = User::nonDeleted()->where('role', User::ROLE_ADMIN)->get(); + foreach ($admins as $admin) { + $mailbox_user = $admin->mailboxesWithSettings()->where('mailbox_id', $id)->first(); + if (!$mailbox_user) { + // Admin may not be connected to the mailbox yet + $admin->mailboxes()->attach($id); + $mailbox_user = $admin->mailboxesWithSettings()->where('mailbox_id', $id)->first(); + } + $mailbox_user->settings->hide = (isset($request->managers[$admin->id]['hide']) ? (int)$request->managers[$admin->id]['hide'] : false); + $mailbox_user->settings->save(); + } + + // Sets the mailbox_user.access array + $mailbox_users = $mailbox->users; + foreach ($mailbox_users as $mailbox_user) { + $access = []; + $mailbox_with_settings = $mailbox_user->mailboxesWithSettings()->where('mailbox_id', $id)->first(); + + foreach (Mailbox::$access_permissions as $perm) { + if (!empty($request->managers[$mailbox_user->id]['access'][$perm])) { + $access[] = $request->managers[$mailbox_user->id]['access'][$perm]; + } + } + + if ($user->id == $mailbox_user->id && !$user->isAdmin()) { + // User with Permission priv's can't edit their own additional priv's. + } else { + $mailbox_with_settings->settings->access = json_encode($access); + } + $mailbox_with_settings->settings->hide = (isset($request->managers[$mailbox_user->id]['hide']) ? (int)$request->managers[$mailbox_user->id]['hide'] : false); + $mailbox_with_settings->settings->save(); + } + + \Session::flash('flash_success_floating', __('Mailbox permissions saved!')); + + return redirect()->route('mailboxes.permissions', ['id' => $id]); + } + + /** + * Mailbox connection settings. + */ + public function connectionOutgoing($id) + { + $mailbox = Mailbox::findOrFail($id); + $this->authorize('admin', $mailbox); + + return view('mailboxes/connection', ['mailbox' => $mailbox, 'sendmail_path' => ini_get('sendmail_path'), 'flashes' => $this->mailboxActiveWarning($mailbox)]); + } + + /** + * Save mailbox connection settings. + */ + public function connectionOutgoingSave($id, Request $request) + { + $mailbox = Mailbox::findOrFail($id); + $this->authorize('admin', $mailbox); + + if ($request->out_method == Mailbox::OUT_METHOD_SMTP) { + $validator = Validator::make($request->all(), [ + 'out_server' => 'required|string|max:255', + 'out_port' => 'required|integer', + 'out_username' => 'nullable|string|max:100', + 'out_password' => 'nullable|string|max:255', + 'out_encryption' => 'required|integer', + ]); + + if ($validator->fails()) { + return redirect()->route('mailboxes.connection', ['id' => $id]) + ->withErrors($validator) + ->withInput(); + } + } + + // Do not save dummy password. + if (preg_match("/^\*+$/", $request->out_password ?? '')) { + $params = $request->except(['out_password']); + } else { + $params = $request->all(); + } + $mailbox->fill($params); + $mailbox->save(); + + if (!empty($request->send_test_to)) { + \Option::set('send_test_to', $request->send_test_to); + } + + // Sometimes background job continues to use old connection settings. + \Helper::queueWorkerRestart(); + + \Session::flash('flash_success_floating', __('Connection settings saved!')); + + return redirect()->route('mailboxes.connection', ['id' => $id]); + } + + /** + * Mailbox incoming settings. + */ + public function connectionIncoming($id, Request $request) + { + $mailbox = Mailbox::findOrFail($id); + $this->authorize('admin', $mailbox); + + $fields = [ + 'in_server' => $mailbox->in_server ?? '', + 'in_port' => $mailbox->in_port ?? '', + 'in_username' => $mailbox->in_username ?? '', + 'in_password' => $mailbox->in_password ?? '', + ]; + + $validator = Validator::make($fields, [ + 'in_server' => 'required', + 'in_port' => 'required', + 'in_username' => 'required', + 'in_password' => 'required', + ]); + + return view('mailboxes/connection_incoming', ['mailbox' => $mailbox, 'flashes' => $this->mailboxActiveWarning($mailbox)])->withErrors($validator); + } + + /** + * Save mailbox connection settings. + */ + public function connectionIncomingSave($id, Request $request) + { + $mailbox = Mailbox::findOrFail($id); + $this->authorize('admin', $mailbox); + + // $validator = Validator::make($request->all(), [ + // 'in_server' => 'nullable|string|max:255', + // 'in_port' => 'nullable|integer', + // 'in_username' => 'nullable|string|max:100', + // 'in_password' => 'nullable|string|max:255', + // ]); + + // if ($validator->fails()) { + // return redirect()->route('mailboxes.connection.incoming', ['id' => $id]) + // ->withErrors($validator) + // ->withInput(); + // } + + // Checkboxes + $request->merge([ + 'in_validate_cert' => ($request->filled('in_validate_cert') ?? false), + ]); + + // Do not save dummy password. + if (preg_match("/^\*+$/", $request->in_password ?? '')) { + $params = $request->except(['in_password']); + } else { + $params = $request->all(); + } + + \Eventy::action('mailbox.incoming_settings_before_save', $mailbox, $request); + + $mailbox->fill($params); + + // Save IMAP Folders. + // Save all custom folders except INBOX. + $in_imap_folders = []; + if (is_array($request->in_imap_folders)) { + foreach ($request->in_imap_folders as $imap_folder) { + $in_imap_folders[] = $imap_folder; + } + } + $mailbox->setInImapFolders($in_imap_folders); + + $mailbox->save(); + + \Session::flash('flash_success_floating', __('Connection settings saved!')); + + return redirect()->route('mailboxes.connection.incoming', ['id' => $id]); + } + + /** + * View mailbox. + */ + public function view(Request $request, $id, $folder_id = null) + { + $user = auth()->user(); + + $mailbox = Mailbox::findOrFailWithSettings($id, $user->id); + $this->authorize('viewCached', $mailbox); + + $folders = $mailbox->getAssesibleFolders(); + + $folder = null; + if (!empty($folder_id)) { + $folder = $folders->filter(function ($item) use ($folder_id) { + return $item->id == $folder_id; + })->first(); + } + // By default we display Unassigned folder + if (empty($folder)) { + $folder = $folders->filter(function ($item) { + return $item->type == Folder::TYPE_UNASSIGNED; + })->first(); + } + + $this->authorize('view', $folder); + + $query_conversations = Conversation::getQueryByFolder($folder, $user->id); + $conversations = $folder->queryAddOrderBy($query_conversations)->paginate( + Conversation::DEFAULT_LIST_SIZE, ['*'], 'page', $request->get('page') + ); + + return view('mailboxes/view', [ + 'mailbox' => $mailbox, + 'folders' => $folders, + 'folder' => $folder, + 'conversations' => $conversations, + ]); + } + + private function mailboxActiveWarning($mailbox) + { + $flashes = []; + + if ($mailbox && \Auth::user()->can('admin', $mailbox)) { + if (Route::currentRouteName() != 'mailboxes.connection' && !$mailbox->isOutActive()) { + $flashes[] = [ + 'type' => 'warning', + 'text' => __('Sending emails need to be configured for the mailbox in order to send emails to customers and support agents').' ('.__('Connection Settings').' » '.__('Sending Emails').')', + 'unescaped' => true, + ]; + } + if (Route::currentRouteName() != 'mailboxes.connection.incoming' && !$mailbox->isInActive()) { + $flashes[] = [ + 'type' => 'warning', + 'text' => __('Receiving emails need to be configured for the mailbox in order to fetch emails from your support email address').' ('.__('Connection Settings').' » '.__('Receiving Emails').')', + 'unescaped' => true, + ]; + } + } + + return $flashes; + } + + /** + * Auto reply settings. + */ + public function autoReply($id) + { + $mailbox = Mailbox::findOrFail($id); + $this->authorize('updateAutoReply', $mailbox); + + if (!$mailbox->auto_reply_subject) { + $mailbox->auto_reply_subject = 'Re: {%subject%}'; + } + + return view('mailboxes/auto_reply', [ + 'mailbox' => $mailbox, + ]); + } + + /** + * Save auto reply settings. + */ + public function autoReplySave($id, Request $request) + { + $mailbox = Mailbox::findOrFail($id); + +// $this->authorize('update', $mailbox); + $this->authorize('updateAutoReply', $mailbox); + + $request->merge([ + 'auto_reply_enabled' => ($request->filled('auto_reply_enabled') ?? false), + ]); + + if ($request->auto_reply_enabled) { + $post = $request->all(); + $post['auto_reply_message'] = strip_tags($post['auto_reply_message']); + $validator = Validator::make($post, [ + 'auto_reply_subject' => 'required|string|max:128', + 'auto_reply_message' => 'required|string', + ]); + $validator->setAttributeNames([ + 'auto_reply_subject' => __('Subject'), + 'auto_reply_message' => __('Message'), + ]); + + if ($validator->fails()) { + return redirect()->route('mailboxes.auto_reply', ['id' => $id]) + ->withErrors($validator) + ->withInput(); + } + } + + $mailbox->fill($request->all()); + + $mailbox->save(); + + \Session::flash('flash_success_floating', __('Auto Reply status saved')); + + return redirect()->route('mailboxes.auto_reply', ['id' => $id]); + } + + /** + * Auto reply settings. + */ + public function emailSignature($id) + { + $mailbox = Mailbox::findOrFail($id); + $this->authorize('updateAutoReply', $mailbox); + + if (!$mailbox->auto_reply_subject) { + $mailbox->auto_reply_subject = 'Re: {%subject%}'; + } + + return view('mailboxes/email_signature', [ + 'mailbox' => $mailbox, + ]); + } + + + /** + * 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': + $mailbox = Mailbox::find($request->mailbox_id); + + if (!$mailbox) { + $response['msg'] = __('Mailbox not found'); + } elseif (!$user->can('admin', $mailbox)) { + $response['msg'] = __('Not enough permissions'); + } elseif (empty($request->to)) { + $response['msg'] = __('Please specify recipient of the test email'); + } + + // Check if outgoing port is open. + if (!$response['msg'] && $mailbox->out_method == Mailbox::OUT_METHOD_SMTP) { + $test_result = \Helper::checkPort($mailbox->out_server, $mailbox->out_port); + if (!$test_result) { + $response['msg'] = __(':host is not available on :port port. Make sure that :host address is correct and that outgoing port :port on YOUR server is open.', ['host' => ''.$mailbox->out_server.'', 'port' => ''.$mailbox->out_port.'']); + } + } + + if (!$response['msg']) { + $test_result = false; + + try { + $test_result = \App\Misc\Mail::sendTestMail($request->to, $mailbox); + } 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; + + // Test sending emails from mailbox + case 'fetch_test': + $mailbox = Mailbox::find($request->mailbox_id); + + if (!$mailbox) { + $response['msg'] = __('Mailbox not found'); + } elseif (!$user->can('admin', $mailbox)) { + $response['msg'] = __('Not enough permissions'); + } + + $response = \Eventy::filter('mailbox.fetch_test', $response, $mailbox); + + $tested = (isset($response['tested']) && $response['tested'] === true); + + // Check if outgoing port is open. + if (!$response['msg'] && !$tested) { + $test_result = \Helper::checkPort($mailbox->in_server, $mailbox->in_port); + if (!$test_result) { + $response['msg'] = __(':host is not available on :port port. Make sure that :host address is correct and that outgoing port :port on YOUR server is open.', ['host' => ''.$mailbox->in_server.'', 'port' => ''.$mailbox->in_port.'']); + } + } + + if (!$response['msg'] && !$tested) { + $test_result = false; + + try { + $test_result = \MailHelper::fetchTest($mailbox); + } catch (\Exception $e) { + $response['msg'] = $e->getMessage(); + } + + if (!$test_result && !$response['msg']) { + $response['msg'] = __('Error occurred connecting to the server'); + } + } + + if (!$response['msg'] && !$tested) { + $response['status'] = 'success'; + } + break; + + // Retrieve a list of available IMAP folders from server. + case 'imap_folders': + $mailbox = Mailbox::find($request->mailbox_id); + + if (!$mailbox) { + $response['msg'] = __('Mailbox not found'); + } elseif (!$user->can('admin', $mailbox)) { + $response['msg'] = __('Not enough permissions'); + } + + $response['folders'] = []; + + if (!$response['msg']) { + + try { + $client = \MailHelper::getMailboxClient($mailbox); + $client->connect(); + + $imap_folders = $client->getFolders(); + + if (count($imap_folders)) { + foreach ($imap_folders as $imap_folder) { + if (!empty($imap_folder->name)) { + $response['folders'][] = $imap_folder->name; + } + // Maybe we need a recursion here. + if (!empty($imap_folder->children)) { + foreach ($imap_folder->children as $child_imap_folder) { + // Old library. + if (!empty($child_imap_folder->fullName)) { + $response['folders'][] = $child_imap_folder->fullName; + } + // New library. + if (!empty($child_imap_folder->full_name)) { + $response['folders'][] = $child_imap_folder->full_name; + } + } + } + } + } + + if (count($response['folders'])) { + $response['msg_success'] = __('IMAP folders retrieved: '.implode(', ', $response['folders'])); + } else { + $response['msg_success'] = __('Connected, but no IMAP folders found'); + } + + } catch (\Exception $e) { + $response['msg'] = $e->getMessage(); + } + } + + if (!$response['msg']) { + $response['status'] = 'success'; + } + break; + + // Delete mailbox + case 'delete_mailbox': + $mailbox = Mailbox::find($request->mailbox_id); + + if (!$mailbox) { + $response['msg'] = __('Mailbox not found'); + } elseif (!$user->can('admin', $mailbox)) { + $response['msg'] = __('Not enough permissions'); + } elseif (!$user->isDummyPassword() && !Hash::check($request->password ?? '', $user->password)) { + $response['msg'] = __('Please double check your password, and try again'); + } + + if (!$response['msg']) { + + // Remove threads and conversations. + $conversation_ids = $mailbox->conversations()->pluck('id')->toArray(); + + for ($i=0; $i < ceil(count($conversation_ids) / \Helper::IN_LIMIT); $i++) { + $slice_ids = array_slice($conversation_ids, $i*\Helper::IN_LIMIT, \Helper::IN_LIMIT); + Thread::whereIn('conversation_id', $slice_ids)->delete(); + } + + $mailbox->conversations()->delete(); + $mailbox->users()->sync([]); + $mailbox->folders()->delete(); + // Maybe remove notifications on events in this mailbox? + + $mailbox->delete(); + + \Session::flash('flash_success_floating', __('Mailbox deleted')); + + $response['status'] = 'success'; + } + break; + + // Mute notifications + case 'mute': + $mailbox = Mailbox::find($request->mailbox_id); + + if (!$mailbox) { + $response['msg'] = __('Mailbox not found'); + } + + if (!$response['msg']) { + + $mailbox_user = $user->mailboxesWithSettings()->where('mailbox_id', $mailbox->id)->first(); + if (!$mailbox_user) { + // User may not be connected to the mailbox yet + $user->mailboxes()->attach($mailbox->id); + $mailbox_user = $user->mailboxesWithSettings()->where('mailbox_id', $mailbox->id)->first(); + } + $mailbox_user->settings->mute = (bool)$request->mute; + $mailbox_user->settings->save(); + + $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); + } + + public function oauth(Request $request) + { + $mailbox_id = $request->id ?? ''; + $provider = $request->provider ?? ''; + + $state_data = []; + if (!empty($request->state)) { + $state_data = json_decode($request->state, true); + if (!empty($state_data['mailbox_id'])) { + $mailbox_id = $state_data['mailbox_id']; + } + if (!empty($state_data['provider'])) { + $provider = $state_data['provider']; + } + } + + // MS Exchange. + if (!empty($request->error) && $request->error == 'invalid_request' && !empty($request->error_description)) { + return htmlspecialchars($request->error_description); + } + + if (empty($provider)) { + return 'Invalid oAuth Provider'; + } + + $mailbox = Mailbox::findOrFail($mailbox_id); + $this->authorize('admin', $mailbox); + + if (empty($mailbox)) { + return __('Mailbox not found').': '.$mailbox_id; + } + if (empty($mailbox->in_username)) { + return 'Enter oAuth Client ID as Username and save mailbox settings'; + } + if (empty($mailbox->in_password)) { + return 'Enter oAuth Client Secret as Password and save mailbox settings'; + } + + $session_data = []; + if (\Session::get('mailbox_oauth_'.$provider.'_'.$mailbox_id)) { + $session_data = \Session::get('mailbox_oauth_'.$provider.'_'.$mailbox_id); + } + + if (empty($request->code)) { + $state = [ + 'provider' => $provider, + 'mailbox_id' => $mailbox_id, + 'state' => crc32($mailbox->in_username.$mailbox->in_password), + ]; + $url = \MailHelper::oauthGetAuthorizationUrl(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [ + 'state' => json_encode($state), + 'client_id' => $mailbox->in_username, + ]); + if ($url) { + \Session::put('mailbox_oauth_'.$provider.'_'.$mailbox_id, $state); + // [ + // 'provider' => $request->provider, + // 'mailbox_id' => $request->mailbox_id, + // 'state' => $provider->getState(), + // ]); + return redirect()->away($url); + } else { + return 'Could not generate authorization URL: check Client ID (Username) and Client Secret (Password)'; + } + + // Check given state against previously stored one to mitigate CSRF attack + } elseif (empty($request->state) || ($state_data['state'] ?? '') !== ($session_data['state'] ?? '')) { + + \Session::forget('mailbox_oauth_'.$provider.'_'.$mailbox_id); + return 'Invalid oAuth state'; + + } else { + + // 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, + 'code' => $request->code, + ]); + + if (!empty($token_data['a_token'])) { + $mailbox->setMetaParam('oauth', $token_data, true); + } elseif (!empty($token_data['error'])) { + return __('Error occurred').': '.htmlspecialchars($token_data['error']); + } + + return redirect()->route('mailboxes.connection.incoming', ['id' => $mailbox_id]); + } + } + + public function oauthDisconnect(Request $request) + { + $mailbox_id = $request->id ?? ''; + $provider = $request->provider ?? ''; + + $mailbox = Mailbox::findOrFail($mailbox_id); + $this->authorize('admin', $mailbox); + + // oAuth Disconnect. + $mailbox->removeMetaParam('oauth', true); + return \MailHelper::oauthDisconnect($provider, route('mailboxes.connection.incoming', ['id' => $mailbox_id])); + } +} diff --git a/freescout-dist/app/Http/Controllers/ModulesController.php b/freescout-dist/app/Http/Controllers/ModulesController.php new file mode 100644 index 0000000..e59d8e7 --- /dev/null +++ b/freescout-dist/app/Http/Controllers/ModulesController.php @@ -0,0 +1,570 @@ +middleware('auth'); + } + + /** + * Modules. + */ + public function modules(Request $request) + { + $installed_modules = []; + $modules_directory = []; + $third_party_modules = []; + $all_modules = []; + $flashes = []; + $updates_available = false; + + $flash = \Cache::get('modules_flash'); + if ($flash) { + if (is_array($flash) && !isset($flash['text'])) { + $flashes = $flash; + } else { + $flashes[] = $flash; + } + \Cache::forget('modules_flash'); + } + + // Get available modules and cache them + if (\Cache::has('modules_directory')) { + $modules_directory = \Cache::get('modules_directory'); + } + + if (!$modules_directory) { + $modules_directory = WpApi::getModules(); + if ($modules_directory && is_array($modules_directory) && count($modules_directory)) { + \Cache::put('modules_directory', $modules_directory, now()->addMinutes(15)); + } + } + + // Get installed modules + \Module::clearCache(); + $modules = \Module::all(); + foreach ($modules as $module) { + $module_data = [ + 'alias' => $module->getAlias(), + 'name' => $module->getName(), + 'description' => $module->getDescription(), + 'version' => $module->get('version'), + 'detailsUrl' => $module->get('detailsUrl'), + 'author' => $module->get('author'), + 'authorUrl' => $module->get('authorUrl'), + 'requiredAppVersion' => $module->get('requiredAppVersion'), + 'requiredPhpExtensions' => $module->get('requiredPhpExtensions'), + 'requiredPhpExtensionsMissing' => \App\Module::getMissingExtensions($module->get('requiredPhpExtensions')), + 'requiredModulesMissing' => \App\Module::getMissingModules($module->get('requiredModules'), $modules), + 'img' => $module->get('img'), + 'active' => $module->active(), //\App\Module::isActive($module->getAlias()), + 'installed' => true, + 'activated' => \App\Module::isLicenseActivated($module->getAlias(), $module->get('authorUrl')), + 'license' => \App\Module::getLicense($module->getAlias()), + // Determined later + 'new_version' => '', + ]; + $module_data = \App\Module::formatModuleData($module_data); + $installed_modules[] = $module_data; + } + + // No need, as we update modules list on each page load + // Clear modules cache if any module has been added or removed + // if (count($modules) != count(Module::getCached())) { + // $this->clearCache(); + // } + + // Prepare directory modules + if (is_array($modules_directory)) { + foreach ($modules_directory as $i_dir => $dir_module) { + + $modules_directory[$i_dir] = \App\Module::formatModuleData($dir_module); + + // Remove modules without aliases + if (empty($dir_module['alias'])) { + unset($modules_directory[$i_dir]); + } + $all_modules[$dir_module['alias']] = $dir_module['name']; + foreach ($installed_modules as $i_installed => $module) { + if ($dir_module['alias'] == $module['alias']) { + // Set image from director + $installed_modules[$i_installed]['img'] = $dir_module['img']; + // Remove installed modules from modules directory. + unset($modules_directory[$i_dir]); + + // Detect if new version is available + if (!empty($dir_module['version']) && version_compare($dir_module['version'], $module['version'], '>')) { + $installed_modules[$i_installed]['new_version'] = $dir_module['version']; + $updates_available = true; + } + + continue 2; + } + } + + if (empty($dir_module['authorUrl']) || !\App\Module::isOfficial($dir_module['authorUrl'])) { + unset($modules_directory[$i_dir]); + continue; + } + + if (!empty($dir_module['requiredPhpExtensions'])) { + $modules_directory[$i_dir]['requiredPhpExtensionsMissing'] = \App\Module::getMissingExtensions($dir_module['requiredPhpExtensions']); + } + $modules_directory[$i_dir]['active'] = \App\Module::isActive($dir_module['alias']); + $modules_directory[$i_dir]['activated'] = false; + + // Do not show third-party modules in Modules Derectory. + if (\App\Module::isThirdParty($dir_module)) { + $third_party_modules[] = $modules_directory[$i_dir]; + unset($modules_directory[$i_dir]); + } + } + } else { + $modules_directory = []; + } + + // Check modules symlinks. Somestimes instead of symlinks folders with files appear. + + $invalid_symlinks = \App\Module::checkSymlinks( + collect($installed_modules)->where('active', true)->pluck('alias')->toArray() + ); + + return view('modules/modules', [ + 'installed_modules' => $installed_modules, + 'modules_directory' => $modules_directory, + 'third_party_modules' => $third_party_modules, + 'flashes' => $flashes, + 'updates_available' => $updates_available, + 'all_modules' => $all_modules, + 'invalid_symlinks' => $invalid_symlinks, + ]); + } + + /** + * Ajax. + */ + public function ajax(Request $request) + { + $response = [ + 'status' => 'error', + 'msg' => '', // this is error message + ]; + + switch ($request->action) { + + case 'install': + case 'activate_license': + $license = $request->license; + $alias = $request->alias; + + if (!$license) { + $response['msg'] = __('Empty license key'); + } + + if (!$response['msg']) { + $params = [ + 'license' => $license, + 'module_alias' => $alias, + 'url' => \App\Module::getAppUrl(), + ]; + $result = WpApi::activateLicense($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'] == 'valid') { + if ($request->action == 'install') { + // Download and install module + $license_details = WpApi::getVersion($params); + + if (WpApi::$lastError) { + $response['msg'] = WpApi::$lastError['message']; + } elseif (!empty($license_details['code']) && !empty($license_details['message'])) { + $response['msg'] = $license_details['message']; + } 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) { + $response['msg'] = $e->getMessage(); + } + + $download_error = false; + if (!file_exists($module_archive)) { + $download_error = true; + } else { + // Extract + try { + \Helper::unzip($module_archive, \Module::getPath()); + } catch (\Exception $e) { + $response['msg'] = $e->getMessage(); + } + // Check if extracted module exists + \Module::clearCache(); + $module = \Module::findByAlias($alias); + if (!$module) { + $download_error = true; + } + } + + // Remove archive + if (file_exists($module_archive)) { + \File::delete($module_archive); + } + + if (!$response['msg'] && !$download_error) { + // Activate license + \App\Module::activateLicense($alias, $license); + + \Session::flash('flash_success_floating', __('Module successfully installed!')); + $response['status'] = 'success'; + } elseif ($download_error) { + $response['reload'] = true; + + if ($response['msg']) { + \Session::flash('flash_error_floating', $response['msg']); + } + + \Session::flash('flash_error_unescaped', __('Error occurred downloading the module. Please :%a_being%download:%a_end% module manually and extract into :folder', ['%a_being%' => '', '%a_end%' => '', 'folder' => ''.\Module::getPath().''])); + } + } else { + $response['msg'] = __('Error occurred. Please try again later.'); + } + } else { + // Just activate license + \App\Module::activateLicense($alias, $license); + + \Session::flash('flash_success_floating', __('License successfully activated!')); + $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 'activate': + $alias = $request->alias; + $module = \Module::findByAlias($alias); + + if (!$module) { + $response['msg'] = __('Module not found').': '.$alias; + } + + // Check license + if (!$response['msg']) { + if (!empty($module->get('authorUrl')) && $module->isOfficial()) { + $params = [ + 'license' => $module->getLicense(), + 'module_alias' => $alias, + 'url' => \App\Module::getAppUrl(), + ]; + $license_result = WpApi::checkLicense($params); + + if (!empty($license_result['code']) && !empty($license_result['message'])) { + // Remove remembered license key and deactivate license in DB + \App\Module::deactivateLicense($alias, ''); + + $response['msg'] = $license_result['message']; + } elseif (!empty($license_result['status']) && $license_result['status'] != 'valid' && $license_result['status'] != 'inactive') { + // Remove remembered license key and deactivate license in DB + \App\Module::deactivateLicense($alias, ''); + + switch ($license_result['status']) { + case 'expired': + $response['msg'] = __('License key has expired'); + break; + case 'disabled': + $response['msg'] = __('License key has been revoked'); + break; + case 'inactive': + $response['msg'] = __('License key has not been activated yet'); + case 'site_inactive': + $response['msg'] = __('No activations left for this license key').' ('.__("Use 'Deactivate License' link above to transfer license key from another domain").')'; + break; + } + } elseif (!empty($license_result['status']) && $license_result['status'] == 'inactive') { + // Activate the license. + $result = WpApi::activateLicense($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'] == 'valid') { + // Success. + } elseif (!empty($result['error'])) { + $response['msg'] = \App\Module::getErrorMessage($result['error'], $result); + } else { + // Some unknown error. Do nothing. + } + } + } + } + } + + if (!$response['msg']) { + \App\Module::setActive($alias, true); + + $outputLog = new BufferedOutput(); + \Artisan::call('freescout:module-install', ['module_alias' => $alias], $outputLog); + $output = $outputLog->fetch(); + + // Get module name + $name = '?'; + if ($module) { + $name = $module->getName(); + } + + $type = 'danger'; + $msg = __('Error occurred activating ":name" module', ['name' => $name]); + if (session('flashes_floating') && is_array(session('flashes_floating'))) { + // If there was any error, module has been deactivated via modules.register_error filter + $msg = ''; + foreach (session('flashes_floating') as $flash) { + $msg .= $flash['text'].' '; + } + } elseif (strstr($output, 'Configuration cached successfully')) { + $type = 'success'; + $msg = __('":name" module successfully activated!', ['name' => $name]); + } else { + // Deactivate the module. + \App\Module::setActive($alias, false); + \Artisan::call('freescout:clear-cache'); + } + + // Check public folder. + if ($module && file_exists($module->getPath().DIRECTORY_SEPARATOR.'Public')) { + $symlink_path = public_path().\Module::getPublicPath($alias); + if (!file_exists($symlink_path)) { + $type = 'danger'; + $msg = 'Error occurred creating a module symlink ('.$symlink_path.'). Please check folder permissions.'; + \App\Module::setActive($alias, false); + \Artisan::call('freescout:clear-cache'); + } + } + + if ($type == 'success') { + // Migrate again, in case migration did not work in the moment the module was activated. + \Artisan::call('migrate', ['--force' => true]); + } + + // \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': + $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 = self::stripTags($text); + + $text = mb_substr($text, 0, $length); + + return $text; + } + + /** + * Check if passed route name equals to the current one. + */ + public static function isCurrentRoute($route_name) + { + if (\Request::route()->getName() == $route_name) { + return true; + } else { + return false; + } + } + + /** + * Check if menu item is selected. + * Each menu item has a mnemonic name. + */ + public static function isMenuSelected($menu_item_name) + { + $current_route = \Request::route()->getName(); + + $menu = \Eventy::filter('menu.selected', self::$menu); + + foreach ($menu as $primary_name => $primary_items) { + if (!is_array($primary_items)) { + if ($current_route == $primary_items) { + return $primary_name == $menu_item_name; + } + if ($primary_name == $menu_item_name) { + return false; + } + continue; + } + foreach ($primary_items as $secondary_name => $secondary_routes) { + if (is_array($secondary_routes)) { + if (in_array($current_route, $secondary_routes)) { + return $secondary_name == $menu_item_name || $primary_name == $menu_item_name; + } + } elseif (is_string($secondary_name)) { + if ($current_route == $secondary_routes) { + return $secondary_name == $menu_item_name || $primary_name == $menu_item_name; + } + } else { + if ($current_route == $secondary_routes) { + return $primary_name == $menu_item_name || $menu_item_name == $secondary_routes; + } + } + } + } + + return false; + } + + public static function menuSelectedHtml($menu_item_name) + { + if (self::isMenuSelected($menu_item_name)) { + return 'active'; + } else { + return ''; + } + } + + /** + * Resize image without using Intervention package. + */ + public static function resizeImage($file, $mime_type, $thumb_width, $thumb_height) + { + list($width, $height) = getimagesize($file); + if (!$width) { + return false; + } + + if (preg_match('/png/i', $mime_type)) { + $src = imagecreatefrompng($file); + + $kek = imagecolorallocate($src, 255, 255, 255); + imagefill($src, 0, 0, $kek); + } elseif (preg_match('/gif/i', $mime_type)) { + $src = imagecreatefromgif($file); + + $kek = imagecolorallocate($src, 255, 255, 255); + imagefill($src, 0, 0, $kek); + } elseif (preg_match('/bmp/i', $mime_type)) { + $src = imagecreatefrombmp($file); + } else { + $src = imagecreatefromjpeg($file); + } + + $original_aspect = $width / $height; + $thumb_aspect = $thumb_width / $thumb_height; + if ($original_aspect == $thumb_aspect) { + $new_height = $thumb_height; + $new_width = $thumb_width; + } elseif ($original_aspect > $thumb_aspect) { + // If image is wider than thumbnail (in aspect ratio sense) + $new_height = $thumb_height; + $new_width = $width / ($height / $thumb_height); + } else { + // If the thumbnail is wider than the image + $new_width = $thumb_width; + $new_height = $height / ($width / $thumb_width); + } + + $thumb = imagecreatetruecolor($thumb_width, $thumb_height); + // Resize and crop + imagecopyresampled($thumb, + $src, + ceil(0 - ($new_width - $thumb_width) / 2), // Center the image horizontally + ceil(0 - ($new_height - $thumb_height) / 2), // Center the image vertically + 0, 0, + ceil($new_width), + ceil($new_height), + $width, $height); + imagedestroy($src); + + return $thumb; + } + + public static function jsonToArray($json, $exclude_array = []) + { + if ($json) { + $array = json_decode($json, true); + if (json_last_error()) { + $array = [$json]; + } + if ($array && $exclude_array) { + $array = array_diff($array, $exclude_array); + } + + return $array; + } else { + return []; + } + } + + public static function getDomain() + { + return parse_url(\Config::get('app.url'), PHP_URL_HOST); + } + + /** + * Create zip archive. + * Source example: public/files/* + * File name example: test.zip. + * storage_path without app/ + */ + public static function createZipArchive($source, $file_name, $folder = '', $storage_file_path = '') + { + if (!$source || !$file_name) { + return false; + } + $files = glob($source); + + if (!$storage_file_path) { + $storage_file_path = 'zipper'.DIRECTORY_SEPARATOR.$file_name; + } else { + // if (!self::getPrivateStorage()->exists($storage_path)) { + // self::getPrivateStorage()->makeDirectory($storage_path); + // } + } + $dest_path = storage_path().DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.$storage_file_path; + + // If file exists it has to be deleted, otherwise Zipper will add file to the existing archive + if (self::getPrivateStorage()->exists($storage_file_path)) { + self::getPrivateStorage()->delete($storage_file_path); + } + + \Chumper\Zipper\Facades\Zipper::make($dest_path)->folder($folder)->add($files)->close(); + + return $dest_path; + } + + public static function getPrivateStorage() + { + return \Storage::disk('private'); + } + + public static function getPublicStorage() + { + return \Storage::disk('public'); + } + + public static function formatException($e) + { + return 'Error: '.$e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')'; + } + + public static function denyAccess($msg = '') + { + abort(403, $msg ?: 'This action is unauthorized.'); + } + + /** + * Check if application version. + * + * @param [type] $ver [description] + * + * @return [type] [description] + */ + public static function checkAppVersion($version2, $operator = '>=') + { + return version_compare(\Config::get('app.version'), $version2, $operator); + } + + /** + * Download remote file and save as file. + */ + public static function downloadRemoteFile($url, $destinationFilePath) + { + $client = new \GuzzleHttp\Client(); + + try { + $client->request('GET', $url, \Helper::setGuzzleDefaultOptions([ + 'sink' => $destinationFilePath, + 'timeout' => 300, // seconds + 'connect_timeout' => 7, + ])); + } catch (\Exception $e) { + self::logException($e); + } + } + + /** + * Extract ZIP archive. + * to: must be apsolute path, otherwise extracted into /public/$to. + */ + public static function unzip($archive, $to) + { + \Chumper\Zipper\Facades\Zipper::make($archive)->extractTo($to); + } + + public static function logException($e, $prefix = '') + { + if ($prefix) { + $prefix .= ' '; + } + \Log::error($prefix.self::formatException($e)); + } + + /** + * Safely decrypt. + * + * @param [type] $e [description] + * + * @return [type] [description] + */ + public static function decrypt($value) + { + try { + $value = decrypt($value); + } catch (\Exception $e) { + // Do nothing. + } + + return $value; + } + + /** + * Log custom data to activity log. + * + * @param [type] $log_name [description] + * @param [type] $data [description] + * @param [type] $code [description] + * + * @return [type] [description] + */ + public static function log($log_name, $description, $properties = []) + { + activity() + ->withProperties($properties) + ->useLog($log_name) + ->log($description); + } + + /** + * Log exception to activity log. + */ + public static function logExceptionToActivityLog($e, $log_name, $description, $properties = []) + { + $properties['error'] = self::formatException($e); + activity() + ->withProperties($properties) + ->useLog($log_name) + ->log($description); + } + + /** + * Check if folder is writable. + * + * @param [type] $path [description] + * + * @return bool [description] + */ + public static function isFolderWritable($path) + { + if (!file_exists($path)) { + return false; + } + $path = rtrim($path, DIRECTORY_SEPARATOR); + + try { + $file = $path.DIRECTORY_SEPARATOR.'.writable_test'; + if ($file && file_put_contents($file, 'test')) { + unlink($file); + + return true; + } else { + return false; + } + } catch (\Exception $e) { + return false; + } + } + + public static function setLocale($locale) + { + if (in_array($locale, config('app.locales'))) { + app()->setLocale($locale); + } + } + + /** + * Get locale's data. + * + * @param [type] $locale [description] + * @param string $param [description] + * + * @return [type] [description] + */ + public static function getLocaleData($locale, $param = '') + { + if (is_string($locale) && isset(self::$locales[$locale])) { + $data = self::$locales[$locale]; + } else { + return; + } + + if ($param) { + if (isset(self::$locales[$locale])) { + return self::$locales[$locale][$param]; + } else { + return; + } + } else { + return $data; + } + } + + /** + * Clear application cache. + * + * @return [type] [description] + */ + public static function clearCache($options = []) + { + \Artisan::call('freescout:clear-cache', $options); + } + + /** + * Set variable in .evn file. + */ + public static function setEnvFileVar($key, $value) + { + $env_path = app()->environmentFilePath(); + $contents = file_get_contents($env_path); + + if (strstr($value, '"')) { + // Escape quotes. + $value = '"'.str_replace('"', '\"', $value).'"'; + } elseif (!preg_match('/^[a-zA-Z0-9_]+$/', $value) && $value !== '') { + // Add quotes. + $value = '"'.$value.'"'; + } + + $old_value = ''; + // Match the given key at the beginning of a line + preg_match("/^{$key}=[^\r\n]*/m", $contents, $matches); + if (count($matches)) { + $old_value = substr($matches[0], strlen($key) + 1); + } + + if ($old_value) { + // Replace. + $contents = str_replace("{$key}={$old_value}", "{$key}={$value}", $contents); + } else { + // Add or empty value + preg_match("/^{$key}=[\r\n]/m", $contents, $matches); + if (count($matches)) { + // Replace empty value + $contents = str_replace("{$key}=", "{$key}={$value}", $contents); + } else { + // Add. + $contents = $contents."\n{$key}={$value}\n"; + } + } + \File::put($env_path, $contents); + } + + /** + * User may add an extra translation to the app on Translate page. + * + * @return [type] [description] + */ + public static function getCustomLocales() + { + return \Barryvdh\TranslationManager\Models\Translation::distinct()->pluck('locale')->toArray(); + } + + /** + * Get built in and custom locales. + * + * @return [type] [description] + */ + public static function getAllLocales() + { + $app_locales = config('app.locales'); + + // User may add an extra translation to the app on Translate page, + // we should allow user to see his custom translations. + $custom_locales = []; + try { + $custom_locales = \Helper::getCustomLocales(); + } catch (\Exception $e) { + // During installation it throws an error as there is no tables yet. + } + + if (count($custom_locales)) { + $app_locales = array_unique(array_merge($app_locales, $custom_locales)); + } + + return $app_locales; + } + + /** + * app()->setLocale() in Localize middleware also changes config('app.locale'), + * so we are keeping real app locale in real_locale parameter. + */ + public static function getRealAppLocale() + { + return config('app.real_locale'); + } + + /** + * Create a backgound job executing specified action. + * + * @return [type] [description] + */ + public static function backgroundAction($action, $params, $delay = 0) + { + $delay = \Eventy::filter('backgound_action.dispatch_delay', $delay, $action, $params); + + $job = \App\Jobs\TriggerAction::dispatch($action, $params); + if ($delay) { + $job->delay($delay); + } + $job->onQueue('default'); + } + + /** + * Convert HTML into the text with \n. + * + * @param [type] $text [description] + */ + public static function htmlToText($text, $embed_images = false, $options = ['width' => 0]) + { + // Process blockquotes. + $text = str_ireplace('
', '
', $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