Compare commits

...

730 Commits

Author SHA1 Message Date
FreeScout
863ad60308 Show latest laravel-YYYY-MM-DD.log file by default in App Logs 2026-10-06 06:37:05 -07:00
FreeScout
23b371c4f0 Take into account archived mailboxes in User::mailboxesIdsCanView() 2026-10-06 01:11:29 -07:00
FreeScout
164303763d Merge pull request #5708 from il2020/fix-tables
Tables in emails: don't break words
2026-10-06 10:24:39 +03:00
il2020
b1e9249cd2 Tables in emails: don't break words 2026-10-06 10:44:23 +04:00
FreeScout
17f060ee9a Merge pull request #5706 from il2020/fix-closing-block
Fix sidebar blocks flicker on mobile when opened/closed
2026-10-06 09:04:32 +03:00
FreeScout
7fe68073f4 Merge pull request #5705 from seansch/fix/mobile-table-width
Fix mobile table width to avoid unnecessary scrolling
2026-10-06 09:00:33 +03:00
FreeScout
d772126165 Merge pull request #5704 from il2020/fix-long-tap
Select conversation on native long press so selection matches haptic feedback
2026-10-06 08:55:52 +03:00
FreeScout
4bf6a84223 Update SECURITY.md 2026-10-05 22:45:19 -07:00
FreeScout
67f23e88a2 Mask DB credentials in logs 2026-10-05 22:25:53 -07:00
FreeScout
cd450e819d Mask DB credentials in logs 2026-10-05 21:39:34 -07:00
FreeScout
b753c63924 Avoid reading user from DB when logging errors - closes #5707 2026-10-05 10:33:03 -07:00
il2020
773981de78 Fix sidebar blocks flicker on mobile when opened/closed 2026-10-05 15:09:09 +04:00
FreeScout
b4332f105f Update Readme 2026-10-04 10:34:28 -07:00
Sean Schoonover
ffd758438d Fix mobile table width to avoid unnecessary scrolling 2026-10-04 12:35:19 -04:00
il2020
a6bb63c9fe Select conversation on native long press so selection matches haptic feedback 2026-10-04 18:00:32 +04:00
FreeScout
bff838fa0e Merge pull request #5703 from il2020/master
Conversations list (mobile): remove the leftover 250px width of the customer line
2026-10-04 16:18:28 +03:00
il2020
7ec40b691a fix 2026-10-04 15:50:57 +04:00
FreeScout
041d7e09b2 Merge pull request #5702 from il2020/fix-height
Mobile conversation list: subject fader covers the row's bottom borde…
2026-10-04 14:08:43 +03:00
FreeScout
a8290b421d Merge pull request #5701 from il2020/fix-border
Fix thick bottom border of the customer block in conversation on mobile
2026-10-04 13:23:15 +03:00
il2020
8788b4a8e6 Mobile conversation list: subject fader covers the row's bottom border when the mailbox name is shown 2026-10-04 14:19:11 +04:00
il2020
f14358c5fe Fix thick bottom border of the customer block in conversation on mobile 2026-10-04 14:15:46 +04:00
FreeScout
f1b35d0d5f Merge pull request #5700 from il2020/master
Conversations list (mobile): fix the first line of a conversation row
2026-10-04 13:13:09 +03:00
il2020
649d868c62 Conversations list (mobile): fix the first line of a conversation row 2026-10-04 11:38:24 +04:00
FreeScout
8fe96f83a7 Fix conversations list Assignee column width - closes #5695 2026-10-03 22:03:47 -07:00
FreeScout
cf951ea20b Merge pull request #5699 from il2020/master
Conversations list: keep the thread counter visible when the customer name is long
2026-10-04 07:55:07 +03:00
il2020
4de254f7cd Conversations list: keep the thread counter visible when the customer name is long 2026-10-04 00:13:30 +04:00
FreeScout
a1e876f82d Merge pull request #5698 from il2020/master
Allow pinch zoom on Android: add maximum-scale=1 to the viewport meta tag only for non-Android devices
2026-10-03 20:09:20 +03:00
il2020
48c4fac6b4 Allow pinch zoom on Android: add maximum-scale=1 to the viewport meta tag only for non-Android devices 2026-10-03 19:11:49 +04:00
FreeScout
eec09ff173 Merge pull request #5697 from il2020/master
Keep aspect ratio of shrunk images with inline height in conversation view
2026-10-03 17:33:16 +03:00
FreeScout
63546323af Update German translation 2026-10-03 06:50:00 -07:00
il2020
4a8bd527e3 Keep aspect ratio of shrunk images with inline height in conversation view 2026-10-03 16:45:02 +04:00
FreeScout
4de16c62b8 Add __safe_raw_html() to translation manager config 2026-10-03 05:36:18 -07:00
FreeScout
917b4e0ce1 v1.8.245 2026-10-03 03:19:50 -07:00
FreeScout
68fe730581 Merge pull request #5696 from il2020/master
Fix "Class Symfony\Component\Filesystem\Filesystem not found" in Helper::isAbasolutePath()
2026-10-03 13:08:39 +03:00
il2020
c2c2de734e Update Helper.php 2026-10-03 11:20:46 +04:00
FreeScout
7b5ab22381 v1.8.244 2026-10-02 21:29:43 -07:00
FreeScout
7f0e612a15 Add Customer Visibility option to Settings 2026-10-02 06:35:24 -07:00
FreeScout
65e963e163 Customers table view mode 2026-10-02 04:22:53 -07:00
FreeScout
ae038154db Style adjustments 2026-10-02 03:22:52 -07:00
FreeScout
dcd178b3d5 Fit wide tables in thread content #5694 from il2020/master
Fit wide tables in thread content
2026-10-02 13:13:33 +03:00
FreeScout
f594564630 Open the next active conversation after deleting, per the after-send setting #5693 from JayOfTheKeyboard/feature/delete-open-next
Open the next active conversation after deleting, per the after-send setting
2026-10-02 12:42:33 +03:00
il2020
73e2d260a7 Fit wide tables in thread content 2026-10-02 13:38:19 +04:00
FreeScout
2eb76242a6 Focus the primary button in confirmation dialogs so Enter confirms #5692 from JayOfTheKeyboard/fix/confirm-dialog-enter
Focus the primary button in confirmation dialogs so Enter confirms
2026-10-02 12:34:31 +03:00
FreeScout
405c9de596 Update pt-PT translation 2026-10-01 04:16:39 -07:00
FreeScout
9921987a88 Remove target=_blank from non-external links - closes #5086 2026-10-01 04:01:56 -07:00
FreeScout
24f35e170e Change customer when received a reply to a conversation from completely new email address - closes #5690 2026-09-30 11:41:17 -07:00
FreeScout
659e6e8813 Fix creating lists in editor - closes #5681 2026-09-30 07:39:35 -07:00
JayOfTheKeyboard
c299a6e4b0 Open the next active conversation after deleting, per the after-send setting
After deleting a conversation FreeScout always returns to the folder. When the user's after-send setting is "Next active conversation", open the next active conversation instead, as changing the status already does. Other settings keep returning to the folder. The next conversation is found before the delete, while the conversation is still in its folder.
2026-09-30 20:43:50 +10:00
JayOfTheKeyboard
544829d7ea Focus the primary button in confirmation dialogs so Enter confirms
Confirmation dialogs opened with showModalDialog() leave focus on the modal container, so Enter does nothing and Tab starts from the close button. Focus the primary button after the dialog body is inserted. Dialogs with a field to fill in (such as Delete User, which asks to type DELETE) are left as they are.
2026-09-30 20:43:45 +10:00
FreeScout
2f1739e817 Keep aspect ratio of images shrunk by max-width in conversation view #5687 from il2020/master
Keep aspect ratio of images shrunk by max-width in conversation view
2026-09-30 13:40:37 +03:00
il2020
1beaa0b60e Keep aspect ratio of images shrunk by max-width in conversation view 2026-09-30 14:28:59 +04:00
FreeScout
f46f1d678b Fit wide images in user email notifications #5686 from il2020/master
Fit wide images in user email notifications
2026-09-30 12:49:02 +03:00
FreeScout
c5d8acb937 Make icon buttons and menu toggles reachable and usable with the keyboard #5683 from JayOfTheKeyboard/fix/toolbar-keyboard-access
Make icon buttons and menu toggles reachable and usable with the keyboard
2026-09-30 12:37:39 +03:00
il2020
c96b24a7da Fit wide images in user email notifications 2026-09-30 13:36:18 +04:00
FreeScout
78e987c920 Adjust the time alignment in a user notification #5684 from il2020/master
Adjust the time alignment in a user notification
2026-09-30 11:54:24 +03:00
il2020
dffbf0edf2 adjust the time in a user notification 2026-09-30 10:11:18 +04:00
JayOfTheKeyboard
d661beeea2 Make icon buttons and menu toggles reachable and usable with the keyboard
The conversation toolbar icons (Reply, Note, Delete, More Actions), the star in the conversation and in the list, each thread's options menu and the mailbox and user sidebar menus are span/i elements with role="button" (or no role) and no tabindex, so Tab skips them and Enter or Space does nothing. Add tabindex="0" (only where the menu is rendered, so there are no empty Tab stops) and a delegated keydown handler that clicks role="button" spans and icons on Enter and Space, ignoring key repeat. Space on a dropdown toggle is left to Bootstrap, which already opens it.
2026-09-30 15:54:37 +10:00
FreeScout
929c575d28 Add To without space before < in WebklexHeaderTest - closes #5678 2026-09-29 05:57:23 -07:00
FreeScout
42d753acbb Add To without space before < in WebklexHeaderTest - closes #5678 2026-09-29 05:50:28 -07:00
FreeScout
56867a0b79 Fix parsing of addresses without a space between display name and < #5678 from il2020/master
Fix parsing of addresses without a space between display name and <
2026-09-29 15:45:57 +03:00
FreeScout
8d4eb9b899 Fix fetching several messages when one of them fails - closes #5668 2026-09-29 05:43:22 -07:00
FreeScout
be242eaa92 update.sh reports "latest version" when exactly one new commit is available #5679 from il2020/fix/update-sh-commit-count
update.sh reports "latest version" when exactly one new commit is available
2026-09-29 15:36:41 +03:00
FreeScout
11663cf58d Fix SsrfProtectionTest 2026-09-29 05:34:32 -07:00
il2020
24e2fcaf20 update.sh reports "latest version" when exactly one new commit is available 2026-09-29 12:23:41 +00:00
FreeScout
0f87c3e50f Improve sanitizing remote URLs - GHSA-qgjh-pmpj-85m6 2026-09-29 04:28:54 -07:00
il2020
a42a2a7134 Fix parsing of addresses without a space between display name and < 2026-09-29 11:23:44 +04:00
FreeScout
c224ec4ef5 Outlook conditional comments <![if !vml]> / <![endif]> shown as text in thread body #5676 from il2020/vml-fix
Outlook conditional comments <![if !vml]> / <![endif]> shown as text in thread body
2026-09-28 14:15:16 +03:00
FreeScout
7ccaee0518 Fix forwarding attachments with non-local storage - closes #5675 2026-09-28 03:57:43 -07:00
FreeScout
7e90331746 Fix restoring resipients after discarding a draft - closes #5674 2026-09-27 23:34:59 -07:00
il2020
86a120da0c Outlook conditional comments <![if !vml]> / <![endif]> shown as text in thread body 2026-09-28 10:31:12 +04:00
FreeScout
89bc9265b8 Fix restoring Cc and Bcc after Undoing sending - closes #5674 2026-09-27 23:17:07 -07:00
FreeScout
a287c272bf Style adjustments 2026-09-27 22:59:32 -07:00
FreeScout
d709f731a8 Style adjustments 2026-09-27 22:49:58 -07:00
FreeScout
57b8f79482 Fix Helper::getPrivateStorage() - closes #5675 2026-09-27 21:14:25 -07:00
FreeScout
2dfa1fcb39 v1.8.243 2026-09-27 03:41:00 -07:00
FreeScout
1a9c5b8d08 Fix opening attachments - closes #5673 2026-09-27 03:36:02 -07:00
FreeScout
243320ddb9 Conversation list: fix viewer badge showing the current user, show all viewers 2026-09-26 23:45:04 -07:00
FreeScout
2265016523 Add error handling to Attachment::getFileContents() 2026-09-26 23:27:29 -07:00
FreeScout
1a1dbcc5e0 Conversation list: fix viewer badge showing the current user #5670 from il2020/master
Conversation list: fix viewer badge showing the current user
2026-09-27 07:45:57 +03:00
FreeScout
003d2070c6 Improve attachments size check - closes #5666 2026-09-26 03:41:30 -07:00
FreeScout
c422882ff8 Lowercase file name before comparing in OpenController - closes #5669 2026-09-26 03:04:23 -07:00
FreeScout
3e194faa76 v1.8.242 2026-09-25 23:02:56 -07:00
FreeScout
17037f219d Fix failed() method in SendReplyToCustomer - closes #5671 2026-09-25 22:38:40 -07:00
FreeScout
8db5546bab Update readme 2026-09-25 11:22:20 -07:00
il2020
95c4acdd3a Conversation list: fix viewer badge showing the current user, show all viewers 2026-09-25 15:59:35 +04:00
FreeScout
1a49198844 Check attachment file existence in Attachment::duplicate() 2026-09-24 23:36:14 -07:00
FreeScout
8453429753 Add csrf token to Clone Conversation action - GHSA-qp58-4g6r-qq78 2026-09-24 21:46:25 -07:00
FreeScout
12adf2470c Make storage system compatible with remote storages - closes #5603 2026-09-23 21:53:38 -07:00
FreeScout
e3e8b521f1 Add testFromToCc() - closes #5659 2026-09-23 03:07:51 -07:00
FreeScout
46f1bc4658 Merge pull request #5659 from il2020/master
Strip RFC 5322 quotes from display names in `From`/`To`/`Cc`
2026-09-23 09:27:12 +03:00
il2020
24d0177cf8 Strip RFC 5322 quotes from display names in From/To/Cc 2026-09-23 09:40:56 +04:00
FreeScout
96523cf6d9 Allow dl, dt and dd to purifier config - closes #5654 2026-09-22 03:18:50 -07:00
FreeScout
2221777f3a Fix Attempt to read property id on null in Thread::getFromIfDifferentFromReplyTo() - closes #5655 2026-09-21 21:13:55 -07:00
FreeScout
05d45d3037 WebklexHeaderTest - closes #5653 2026-09-19 10:56:50 -07:00
FreeScout
fd24aa6dbd Fix extra space inserted into long customer names #5653 from il2020/master
Fix extra space inserted into long customer names
2026-09-19 20:18:16 +03:00
il2020
2f6b583bf1 Fix extra space inserted into long customer names 2026-09-19 19:30:34 +04:00
FreeScout
0489a6c29f Trim ending space from users without last name - closes #5651 2026-09-19 06:35:21 -07:00
FreeScout
7c64e53126 Fix default module icon when app is used in subfolder 2026-09-19 06:28:56 -07:00
FreeScout
dbbfff831f Fix Mailbox::excludeArchived() - closes #5647 2026-09-19 03:07:25 -07:00
FreeScout
01f5a69e6a v1.8.241 2026-09-18 21:15:57 -07:00
FreeScout
e37596f1b6 Update German translation 2026-09-17 23:02:20 -07:00
FreeScout
c0248c2a8e Send UserEmailReplyError emails when user has no access to mailbox or conversation - closes #5643 2026-09-17 22:55:10 -07:00
FreeScout
6bc4005fa1 Fix Helper::stripTagsFromArray() - closes #5642 2026-09-17 21:58:46 -07:00
FreeScout
ef99594188 Fix changing conversation customer when APP_LIMIT_USER_CUSTOMER_VISIBILITY enabled - closes #5640 2026-09-16 07:40:25 -07:00
FreeScout
97a561e99e Auto link last entered link - closes #5280 2026-09-16 00:03:36 -07:00
FreeScout
504a7f71dc Remove HTML from port unavailable message 2026-09-15 23:02:22 -07:00
FreeScout
bd7fa38500 Implement client->getLastError() function for webklex/php-imap - closes #5633 2026-09-15 06:45:25 -07:00
FreeScout
29f057aa4c Fix fetching from Stalwart mail server - closes #5581 2026-09-15 06:09:44 -07:00
FreeScout
cd129aa634 Use caching for Conversation::userHasAccessToMailbox() and Conversation::isMailboxArchived() 2026-09-14 23:48:56 -07:00
FreeScout
198d839a4a Hide notifications text when user loses access to mailbox or conversation - GHSA-6545-8c3f-pp6w 2026-09-14 21:50:08 -07:00
FreeScout
945fb2f583 Adjust Modules page 2026-09-14 11:23:31 -07:00
FreeScout
7f977959df Retry "connection failed - SSL operation failed" error on fetching - closes #5623 2026-09-14 07:45:53 -07:00
FreeScout
e60e1cd3c3 Check user access to conversation in SendNotificationToUsers job - GHSA-2j2c-g32w-96vm 2026-09-14 04:46:01 -07:00
FreeScout
b00c3f5f5d Do not allow to save existing conversation as draft - GHSA-6ff4-3w2c-mjj8 2026-09-13 21:53:35 -07:00
FreeScout
ab1c70d413 Check if mailbox is active in Mailbox::userHasAccess() - GHSA-59v9-2qvg-cxw8 2026-09-13 21:18:05 -07:00
FreeScout
9c594f406b Do not show .env file content on final step of Installation Wizard - GHSA-3gv6-4vmm-79pj 2026-09-13 11:11:44 -07:00
FreeScout
5be2d61755 Fix "Show Original" on IMAP servers which reject searching by Message-ID 2026-09-13 03:48:50 -07:00
FreeScout
8ee81b6050 Merge pull request #5633 from il2020/master
Fix "Show Original" on IMAP servers which reject searching by Message-ID
2026-09-13 07:40:27 +03:00
il2020
e5d1065743 Fix "Show Original" on IMAP servers which reject searching by Message-ID 2026-09-12 22:25:46 +04:00
FreeScout
6fae3eecb1 Add Paste as Plain Text editor option - closes #5627 2026-09-12 11:20:09 -07:00
FreeScout
4be0be3982 v1.8.240 2026-09-11 22:58:04 -07:00
FreeScout
aada309876 Add testPlainBodyRead() test - closes #5624 2026-09-11 22:44:32 -07:00
FreeScout
3d5cffc50c Allow data: in frame-src CSP rule - closes #5630 2026-09-11 21:45:16 -07:00
FreeScout
f7a0b56db3 Use user timezone in text notifications - closes #5628 2026-09-11 04:04:06 -07:00
FreeScout
c61d5c97b7 Add America/Vancouver timezone - closes #5622 2026-09-08 19:21:28 -07:00
FreeScout
12fb019cac Use Message-Id from headers for In-Reply-To when message_id is generated - closes #5620 2026-09-07 21:43:00 -07:00
FreeScout
f41709a6cf Use Message-Id from headers for In-Reply-To when message_id is generated - closes #5620 2026-09-07 03:52:23 -07:00
FreeScout
8597a4ca52 Adjust replies counter color 2026-09-07 02:59:44 -07:00
FreeScout
af49c663e0 Add csrf token to Undo Reply action - GHSA-5vw8-4wxh-6mpr 2026-09-07 01:58:57 -07:00
FreeScout
e91f2e410c Update SECURITY.md 2026-09-07 01:27:41 -07:00
FreeScout
d9d02b5258 Add Helper::floatingFlash() function 2026-09-07 00:33:33 -07:00
FreeScout
b386b9034f Automatically rename incrorrectly names Module folders on activation 2026-09-06 03:55:37 -07:00
FreeScout
8667724e0b Add Azerbaijani translation 2026-09-06 00:38:00 -07:00
FreeScout
0567e3906b Merge pull request #5619 from jamalkamaladdin/feat/az-locale
Add Azerbaijani translation
2026-09-06 10:21:22 +03:00
FreeScout
d3034ab4f3 Allow to Archive mailboxes 2026-09-06 00:18:18 -07:00
Jamal
8911f03f50 Add Azerbaijani translation 2026-09-05 16:17:00 +04:00
FreeScout
7c998efe8e Highlight inactive mailboxes in UI 2026-09-05 01:43:37 -07:00
FreeScout
5286397847 Extend mailbox.get_mail_from_name hook parameters 2026-09-04 08:03:57 -07:00
FreeScout
053fff2634 Do not send email to customer via SendReplyToCustomer listener if thread is not set in event - closes #5616 2026-09-04 05:21:02 -07:00
FreeScout
609d065b05 Highlight number of replies in conversations list 2026-09-04 04:01:31 -07:00
FreeScout
730401e757 Highlight number of replies in conversations list 2026-09-04 03:30:22 -07:00
FreeScout
3d01c23f0b Update Readme 2026-09-04 01:37:36 -07:00
FreeScout
6420feed47 Fix To field in notes created when forwarding to multiple recipients - closes #5441 2026-09-04 00:54:22 -07:00
FreeScout
c1bc7b90ef Update Modules page menu 2026-09-03 23:53:59 -07:00
FreeScout
ce56346290 Update Readme 2026-09-03 23:16:00 -07:00
FreeScout
34537a1834 v1.8.239 2026-09-03 22:13:06 -07:00
FreeScout
8293c064d9 Keep CRLF header separators in MailTransport so mail() output stays valid MIME - closes #5615 2026-09-03 10:28:09 -07:00
FreeScout
1d115c6be8 Improve htmlEscape() in main.js 2026-09-03 09:55:13 -07:00
FreeScout
2b71bbadbb Update readme 2026-09-03 07:04:48 -07:00
FreeScout
ad8f305b13 Merge pull request #5611 from jdevalk/fix/preserve-css-custom-properties
Preserve native CSS custom properties during minification
2026-09-03 11:49:29 +03:00
Joost de Valk
09a18e6d0f Fix preserving CSS custom properties during minification 2026-09-03 07:15:21 +02:00
FreeScout
a3cacca368 Drop the dead $uri self-assignment in the laroute overridet #5609 from darkdi/drop-dead-uri-assignment
Drop the dead $uri self-assignment in the laroute override
2026-09-02 19:43:55 +03:00
Dmitry Rantovov
c1d63c7549 Drop the dead $uri self-assignment in the laroute override 2026-09-02 18:53:53 +03:00
FreeScout
b792e25eec Take into account IPv6 transition addresses in Helper::sanitizeRemoteUrl() - GHSA-v5xm-qw3f-qm98 2026-09-01 22:53:04 -07:00
FreeScout
c5b8917747 Extend Thread-Index with a child block on replies so Outlook groups them from alamedaim-apps/outlook-thread-index-child-block
Extend Thread-Index with a child block on replies so Outlook groups them (#4922)
2026-09-02 08:15:47 +03:00
Ross Taylor
891c971bb9 Extend Thread-Index with a child block on replies so Outlook groups them (#4922)
Outlook groups conversations by Thread-Index and expects a reply to extend the
parent's index with a 5-byte child block (MS-OXOMSG 2.2.1.3). Echoing the
customer's index verbatim makes Outlook open a new conversation, leaving the
customer's original message on its own. Only affects replies to mail that
already carried Thread-Index; other senders are unchanged.
2026-09-02 01:48:48 +00:00
FreeScout
31a9856819 Disable support of attachment URLs with MD5 tokens - GHSA-89ww-mfww-5vr6 2026-09-01 09:00:19 -07:00
FreeScout
723598aa27 Allow to set modal class via parameters 2026-09-01 00:42:20 -07:00
FreeScout
188a738528 Helper::getAppLocale() function 2026-08-31 03:24:05 -07:00
FreeScout
5f140b117f Remove black outline when clicking on links in Chrome 2026-08-31 01:00:12 -07:00
FreeScout
9e1ca45169 v1.8.238 2026-08-30 22:48:14 -07:00
FreeScout
5e32a629a3 Improve populating conversation_id field in add_conversation_id_column_to_notifications_table migration 2026-08-30 22:33:22 -07:00
FreeScout
7390179434 Escape message in showFloatingAlert() 2026-08-30 04:13:42 -07:00
FreeScout
95513f1832 Update PHP versions in GitHub tests 2026-08-29 21:35:58 -07:00
FreeScout
ed1a7ca9e3 Allow to change default modal loader 2026-08-29 21:34:25 -07:00
FreeScout
fa86462665 Adjust .note-actions style 2026-08-29 05:05:06 -07:00
FreeScout
d8f21f070b Allow to configure fetching max execution time via APP_FETCH_MAX_EXECUTION_TIME #5605 from alanost/configurable-fetch-max-execution-time
Allow to configure fetching max execution time via APP_FETCH_MAX_EXECUTION_TIME
2026-08-29 14:32:13 +03:00
Alan
e52e641636 Allow to configure fetching max execution time via APP_FETCH_MAX_EXECUTION_TIME 2026-08-29 11:14:28 +00:00
FreeScout
90209e8b54 v1.8.237 2026-08-29 03:00:39 -07:00
FreeScout
c8f4e43aef Change Show Original link case in thread 2026-08-28 07:21:43 -07:00
FreeScout
721bc200aa Update Finnish translation 2026-08-28 07:17:41 -07:00
FreeScout
46bd96f442 Mark notifications as read by convesrsation_id field in notifications table - closes #5600 2026-08-28 04:14:49 -07:00
FreeScout
51543a4be5 Adjust GenerateVars console message 2026-08-28 02:56:45 -07:00
FreeScout
d416863d20 Store vars.js in /js/builds 2026-08-28 01:49:40 -07:00
FreeScout
9dc9053f66 Update PULL_REQUEST_TEMPLATE 2026-08-28 01:16:26 -07:00
FreeScout
89fced362e Add Strict-Transport-Security header 2026-08-28 01:15:53 -07:00
FreeScout
1e886cbff1 Show From when thread's customer differs from conversation's customer - closes #5601 2026-08-27 22:59:13 -07:00
FreeScout
a381fe69ec Show log name in title 2026-08-27 04:34:54 -07:00
FreeScout
78cf02d983 Adjust events parameters 2026-08-27 04:24:11 -07:00
FreeScout
bce23ef2d3 Show action name for TriggerAction jobs in System Status 2026-08-27 04:23:38 -07:00
FreeScout
868061500a Add Strict-Transport-Security header 2026-08-27 02:12:00 -07:00
FreeScout
69dd7a98dd Add X-Content-Type-Options nosniff header 2026-08-27 01:47:26 -07:00
FreeScout
bd54392ca4 Improve audio downloading/playing - closes #5597 2026-08-27 00:13:58 -07:00
FreeScout
4e8e4d80c7 Do not allow unsafe symlinks when UnZipping - GHSA-rw7g-qq32-c669 2026-08-26 02:51:43 -07:00
FreeScout
06e0e85dcc Add comment for APP_LIMIT_USER_CUSTOMER_VISIBILITY parameter 2026-08-26 02:08:57 -07:00
FreeScout
ddbf13b0b5 Fix user notifications when conversation is moved - closes #5596 2026-08-25 09:22:10 -07:00
FreeScout
1cc09c8a6e Require PHP 7.4+ - closes #5595 2026-08-25 06:55:26 -07:00
FreeScout
c0fde64b42 Allow to fetch emails having same Message-IDs - closes #5591 2026-08-25 06:51:48 -07:00
FreeScout
c8b20fbe4a Update Readme 2026-08-25 02:28:00 -07:00
FreeScout
adde156bc2 Update Readme 2026-08-25 02:25:01 -07:00
FreeScout
c1839cca8a Helper::curlClose() 2026-08-25 02:02:42 -07:00
FreeScout
1aa8ffa9cf Adjust design elements - closes #5592 2026-08-24 22:11:42 -07:00
FreeScout
27f752d23f HTML escape folder names in Get Folders 2026-08-24 07:58:55 -07:00
FreeScout
33661df11a Allow to fetch emails having same Message-IDs - closes #5591 2026-08-24 07:51:40 -07:00
FreeScout
f87c43522d Correctly process customer reply to a previous agent reply after moving conversation - closes #5590 2026-08-24 02:15:14 -07:00
FreeScout
1040e0af3c Adjust error message check in FetchEmail for increasing sleep time - closes #5584 2026-08-24 00:28:00 -07:00
FreeScout
fcfb029710 Improve OAuth IMAP token refresh logic to avoid errors in logs every hour - closes #5582 2026-08-24 00:24:36 -07:00
FreeScout
0e573cd6f9 Catalan: use imperative forms per Softcatalà style guide #5589 from losimo/fix/catalan-imperative-forms
Catalan: use imperative forms per Softcatalà style guide
2026-08-24 10:10:25 +03:00
FreeScout
6c2bf1d59b Check authentication in /polycast/connect and require channels parameter in /polycast/receve - GHSA-7x4c-p73f-67x5 2026-08-23 23:55:33 -07:00
FreeScout
e42c59ab88 v1.8.236 2026-08-23 22:28:11 -07:00
Albert Simó
58a49aa806 Catalan: use imperative forms per Softcatalà style guide
The Catalan translation used infinitives for buttons and menu items
("Desar", "Editar", "Eliminar"). The Softcatalà style guide, the
reference for Catalan software localisation, asks for the second
person singular imperative when the user commands the application:
"Desa", "Edita", "Elimina". Confirmation dialogs are the opposite
case, the application addressing the user, so those now read
"Voleu eliminar aquesta conversa?" rather than an imperative.

Also translates strings added in recent releases that had no Catalan
yet, among them the autoplay notice from the chat sound notifications
in 1.8.233, the fetch error threshold settings in 1.8.235 and the
password reset message that no longer reveals whether an account
exists.

159 corrected entries, 128 added.
2026-08-24 00:05:48 +02:00
FreeScout
0f47724cb7 Check access to mailboxes when non-admin creates users - GHSA-w36p-9vhj-x273 2026-08-21 00:43:34 -07:00
FreeScout
38b9a236a8 Do not allow users who can see only assigned conversations delete any conversations in folder via empty_folder - GHSA-6mhr-m8m9-6q6h 2026-08-20 12:05:42 -07:00
FreeScout
c04444bfbf Check folder type in empty_folder action 2026-08-20 11:31:01 -07:00
FreeScout
bbb8f94417 Remove commit from setReplyBody() 2026-08-20 07:45:56 -07:00
FreeScout
198a2afe75 Adjust error message check in FetchEmail for increasing sleep time - closes #5584 2026-08-20 06:14:47 -07:00
FreeScout
002740b267 Trigger user notifications when moving conversations - closes #5580 2026-08-20 02:50:36 -07:00
FreeScout
ef10136cb8 Enlarge dropdown caret element 2026-08-20 02:41:49 -07:00
FreeScout
343a21e457 Allow text/rfc822-headers content type when processing incoming bounce messages - closes #5583 2026-08-20 02:20:40 -07:00
FreeScout
f706284996 Store conversation number in log when sending error occurs - #5571 2026-08-19 22:59:33 -07:00
FreeScout
b5c737cdf8 Update SECURITY.md 2026-08-18 03:55:47 -07:00
FreeScout
26ac7e2c62 Apply module migrations when the Modules folder resolves outside the app folder #5574 from RhinoBag/fix/5569-module-migrate-path
Apply module migrations when the Modules folder resolves outside the app folder
2026-08-17 20:53:32 +03:00
Jörn Bungartz
34d2a019b0 Apply module migrations when the Modules folder is outside the app folder
module:migrate hands the module's migrations folder to the migrate command
after removing base_path() from it. Module::getPath() is a realpath(), so on
installations where the Modules folder is a symlink leading outside of the
application folder (a common container layout: /var/www/html/Modules ->
/data/Modules) base_path() does not occur in that path. The value stays
absolute, migrate prepends base_path() to it anyway (BaseCommand::
getMigrationPaths()), the resulting folder does not exist, and the migration
reports "Nothing to migrate" with exit code 0.

Updating a module through the Modules page then leaves the old schema in
place, because Module::updateModule() relies on freescout:module-install and
nothing reports the failure. Activating a module is not affected, as
ModulesController runs a plain migrate afterwards.

Modules now remember the path they have been scanned from, which still
contains the symlink and is therefore relative to base_path(), and the
module:migrate command builds the --path option from it. The scanned path
also travels through the modules cache, as Collection::toArray() keeps the
resolved path only. When neither path is inside the application folder the
command reports it instead of reporting success.

closes #5569
2026-08-17 10:39:21 -06:00
FreeScout
661c000a0e Fix CTRL+ keyboard shortcuts on Mac - closes #5572 2026-08-17 01:34:41 -07:00
FreeScout
e2a5930a5a Remove temporary SwiftMailer files in freescout:clean-tmp command - closes #5558 2026-08-17 00:16:47 -07:00
FreeScout
7fad593186 JS conversation.show_reply_form hook 2026-08-16 02:54:50 -07:00
FreeScout
752ce0f46b Highlight sender in thread when From is different from Reply-To 2026-08-15 05:30:10 -07:00
FreeScout
980c6533a8 PHP 8.5 compatibility - closes #5568,#5567 2026-08-15 03:24:53 -07:00
FreeScout
9dc66919e1 Highlight sender in thread when From is different from Reply-To 2026-08-15 03:06:31 -07:00
FreeScout
68ba521efa Remove temporary SwiftMailer files in freescout:clean-tmp command - closes #5558 2026-08-15 02:53:09 -07:00
FreeScout
84bf959276 v1.8.235 2026-08-15 02:35:27 -07:00
FreeScout
3d14b81971 Remove folder_id_state index from Conversations 2026-08-15 00:39:16 -07:00
FreeScout
d3bb99dbc1 Fix actor in web notifications - closes #5566 2026-08-14 10:45:40 -07:00
FreeScout
1407a1bfdd Check user access to conversation when processing reply to email notification - GHSA-83xx-8c27-gq78 2026-08-14 10:05:44 -07:00
FreeScout
0f41f5cabb Sanitize HTTP_CF_CONNECTING_IP in Request and escape values in Logs Monitor email - GHSA-9cm3-qvj2-8hg4 2026-08-14 07:42:13 -07:00
FreeScout
21768e1090 Perform user->canSeeOnlyAssignedConversations() check in ConversationsController->conversationsFilterQuery() and Conversation::getChats() - GHSA-c83g-9f2m-9cmw 2026-08-14 07:27:36 -07:00
FreeScout
db4b4e4b4a Check checkIsOnlyAssigned() when editing or deleting user threads - GHSA-89fj-jcr7-2c68 2026-08-14 06:50:29 -07:00
FreeScout
2b3d120a49 Improve preserving spaces in the conversation preview 2026-08-14 05:07:25 -07:00
FreeScout
c9347ee8a9 Add Mailbox::sanitizeSignature() function 2026-08-14 04:47:49 -07:00
FreeScout
47c98e97a4 Fix variables in mailbox signature - closes #5565 2026-08-14 04:40:37 -07:00
FreeScout
f9c8089ed5 Fix sequence parameter in Webklex Message 2026-08-14 04:08:23 -07:00
FreeScout
9bd8a6527c Fix icon tags in Summernote - closes #5564 2026-08-14 02:37:42 -07:00
FreeScout
a49b61d693 v1.8.234 2026-08-14 02:03:25 -07:00
FreeScout
10e8d0c867 Allow CODE and KBD tags in Purifier config - closes #5556 2026-08-14 01:24:33 -07:00
FreeScout
ae55bd7a36 Check curl HTTP code in Helper::getRemoteFileContents() - closes #5552 2026-08-14 01:09:17 -07:00
FreeScout
3101b08d8b Fix path to PostTooLargeException - closes #5560 2026-08-14 00:44:43 -07:00
FreeScout
8d41a382f2 Helper::hashEquals() instead of hash_equals() 2026-08-14 00:37:35 -07:00
FreeScout
8a8dd09a0c Fix conversation ID in BroadcastNotification::fetchPayloadData() - closes #5553 2026-08-14 00:18:13 -07:00
FreeScout
d0ed4fda50 Allow to extend statuses 2026-08-14 00:13:05 -07:00
FreeScout
6e84ce7840 Allow to extend statuses 2026-08-11 20:01:05 -07:00
FreeScout
61c6fe0ec6 Update Readme 2026-08-07 05:49:43 -07:00
FreeScout
e67f09fe7e Add hooks to reply_fancy template 2026-08-07 05:49:06 -07:00
FreeScout
499fcfc6ca v1.8.233 2026-08-06 21:20:52 -07:00
FreeScout
369125c24f Add Helper::carbonDateParse() function 2026-08-06 10:50:40 -07:00
FreeScout
14faec272c Allow style attribute in inline elements in Purifier config - closes #5547 2026-08-06 09:42:35 -07:00
FreeScout
5c55597650 Remove Outlook reply header from replies to email notifications - fixes #5545 from RhinoBag/fix-5545-notification-reply-outlook-header
Remove Outlook reply header from replies to email notifications - fixes #5545
2026-08-06 07:47:34 +03:00
Jörn Bungartz
24e0a9a01b Remove Outlook reply header from replies to email notifications - fixes #5545 2026-08-05 11:47:51 -06:00
FreeScout
8ea4963b75 Fix Outlook reply separator - closes #5544 2026-08-05 10:34:48 -07:00
FreeScout
73832baf03 Update Readme 2026-08-04 09:40:22 -07:00
FreeScout
fab578bb5c Add WAITING_SINCE_AS_FIRST_UNANSWERED_CUSTOMER_MESSAGE .env parameter - closes #5225 2026-08-04 09:36:44 -07:00
FreeScout
ea2820d04a Fix too long References header in emails to customers - closes #5542 2026-08-03 00:06:13 -07:00
FreeScout
c333ea2780 Create forward-note for each recipient when forwarding conversation to multiple recipients - closes #5441 2026-08-02 04:27:14 -07:00
FreeScout
ddadec3c2a Do not insert HR tag on CTRL+ENTER click - closes #4909 2026-08-02 01:55:05 -07:00
FreeScout
f0533a5470 Add commit() method to Summernote to fix CTRL+Z after loading draft - closes #4837 2026-08-02 01:28:26 -07:00
FreeScout
482b002a8e Update Readme 2026-08-01 05:15:32 -07:00
FreeScout
806f3d0228 Update Readme 2026-08-01 04:58:18 -07:00
FreeScout
2e83003030 Add Conversation::setLastReplyAt() function 2026-08-01 01:26:53 -07:00
FreeScout
756549c157 Add WAITING_SINCE_AS_FIRST_UNANSWERED_CUSTOMER_MESSAGE .env parameter - closes #5225 2026-08-01 00:37:14 -07:00
FreeScout
95fa038205 Audio notifications for chat messages 2026-07-31 10:41:27 -07:00
FreeScout
7c4539d445 v1.8.232 2026-07-31 01:05:47 -07:00
FreeScout
90354deb6c Add functions to required functions 2026-07-30 07:29:20 -07:00
FreeScout
2e7ad9a234 Improve Helper::checkUrlIpAndHost() function 2026-07-30 05:38:08 -07:00
FreeScout
9ad3a8403a Improve Helper::checkUrlIpAndHost() function for IPv6 addresses 2026-07-30 00:42:22 -07:00
FreeScout
6141a718c4 Fix messages sorting on fetching - closes #5536 2026-07-29 21:57:27 -07:00
FreeScout
7efcfd9809 Add Helper::sanitizeEmail() function 2026-07-29 01:15:37 -07:00
FreeScout
de383fd9b6 Adapted instructions to recommend setting PHP_PATH in tools.php - closes #5534 2026-07-28 23:14:11 -07:00
FreeScout
bdef99d68e Enable EscapeNonASCIICharacters option only for outgoing replies - closes #5481 2026-07-28 23:04:50 -07:00
FreeScout
403f02f613 Allow to send just image 2026-07-28 07:09:07 -07:00
FreeScout
0f40c152e3 Do not allow regular users with PERM_EDIT_USERS edit admin profiles 2026-07-28 03:28:49 -07:00
FreeScout
d9b96b7a28 Fix case of items in Helper::checkUrlIpAndHost() 2026-07-28 02:13:41 -07:00
FreeScout
f4a84f47e3 Sanitize URL in Customer::setPhotoFromRemoteFile() 2026-07-28 02:12:19 -07:00
FreeScout
2c3d484271 White background for logo-300.png 2026-07-28 01:56:38 -07:00
FreeScout
0d9a508f2c Upgrade ezyang/htmlpurifier library - closes #5529 2026-07-27 06:22:57 -07:00
FreeScout
441e46d65d Update Guzzle to the latest version 2026-07-27 02:25:14 -07:00
FreeScout
582961ec86 guzzlehttp/psr7: Host Confusion via Weak URI Host Validation - GHSA-c2w2-prh8-qm98 2026-07-27 00:38:35 -07:00
FreeScout
31d0e4199f Add Customer::getEmailsAsArray() method 2026-07-27 00:29:11 -07:00
FreeScout
2429b49fdc Add comment to ajaxHtml() action in SystemController 2026-07-26 23:28:29 -07:00
FreeScout
fc89e7df5e Revert changing last_reply_at field update logic - closes #5501 2026-07-26 10:23:00 -07:00
FreeScout
d2e42a91a2 Use hash_equals() to compare hashes 2026-07-26 00:24:38 -07:00
FreeScout
18b74a92f7 Remove non-used actions from CustomersController 2026-07-26 00:14:36 -07:00
FreeScout
b21600e8f9 Fix potential TypeErrors 2026-07-25 23:15:24 -07:00
FreeScout
3eb73bf375 Use hash_equals() when checking attachment token 2026-07-25 22:59:54 -07:00
FreeScout
069b76adaf Add hash to lang.zip name when downloading translations 2026-07-25 22:54:43 -07:00
FreeScout
2a14a079b7 Sanitize module alias on activating license 2026-07-25 22:40:24 -07:00
FreeScout
d74818bbf3 Helper::decryptSoft() 2026-07-25 22:26:12 -07:00
FreeScout
e6e13e20bd Restrict file extensions uploaded by support agents 2026-07-25 21:43:42 -07:00
FreeScout
caafa618db Purify mailbox signature HTML on saving 2026-07-25 09:53:49 -07:00
FreeScout
12863e7ad2 Escape title in symfony ExceptionHandler 2026-07-25 09:39:18 -07:00
FreeScout
85071db541 Throttle ResetPasswordController controller 2026-07-25 08:54:21 -07:00
FreeScout
de967ed952 Tighten throttling Forgot Password requests - GHSA-jvmv-2qcp-7855 2026-07-25 08:18:27 -07:00
FreeScout
a7e85bfcc4 Return empty value in Helper::decrypt() when decryption fails 2026-07-25 06:08:38 -07:00
FreeScout
11f4ed76b3 Use salt in tools.php auth 2026-07-25 05:00:41 -07:00
FreeScout
1c6bd51ef4 Restrict file extensions uploaded by support agents 2026-07-25 04:36:33 -07:00
FreeScout
16d392fb3c v1.8.231 2026-07-25 03:26:06 -07:00
FreeScout
b3a47afa77 Add comment on SVG sanitizing 2026-07-25 00:45:10 -07:00
FreeScout
2ee52deaa1 Disable /update/ routes 2026-07-25 00:30:28 -07:00
FreeScout
acdc9ef5b5 Build Zipball URL from config in Self-updater 2026-07-24 23:07:45 -07:00
FreeScout
ad8f668998 Respect CURLOPT_SSL_VERIFYPEER config parameter in Helper::curlGetNextRedirectedUrl() 2026-07-24 22:23:36 -07:00
FreeScout
970ae6e2ba Do not open redirected URL in Helper::sanitizeRemoteUrl() 2026-07-24 22:19:26 -07:00
FreeScout
979e34dd5c Improve Helper::checkUrlIpAndHost() function for IPv6 addresses 2026-07-24 21:56:57 -07:00
FreeScout
e1f103568d Tighten throttling Forgot Password requests - GHSA-jvmv-2qcp-7855 2026-07-24 21:22:59 -07:00
FreeScout
e6721a094b Make Shebang generic in update.sh and install.sh - closes #5521 2026-07-23 01:48:14 -07:00
FreeScout
5fe3c3838f Show warning on Status page when HTTPS is used but SESSION_SECURE_COOKIE=true is not set 2026-07-23 01:29:29 -07:00
FreeScout
200a2b38ff Adjust bootstrap 2026-07-23 01:00:59 -07:00
FreeScout
1e970caffa Fix auto-focusing the reply area when replying 2026-07-22 23:25:05 -07:00
FreeScout
b12bb42d47 Fix missing space in browser push notifications - closes #5520 2026-07-22 23:17:47 -07:00
FreeScout
72bd06f6d2 Create new conversation for email notifications forwarded to another mailbox - closes #4515 2026-07-22 23:13:49 -07:00
FreeScout
9d16a0d4d3 Create new conversation for email notifications forwarded to another mailbox - closes #4515 2026-07-22 21:50:43 -07:00
FreeScout
e1b4a22383 Fix auto-focusing the reply area when replying 2026-07-21 23:18:53 -07:00
FreeScout
059301b720 Update German translation 2026-07-20 22:40:37 -07:00
FreeScout
6e1b61fbb9 Do not allow to send reply till all uploads finished - closes #5515 2026-07-20 09:24:54 -07:00
FreeScout
24fdc40dc9 PHP 8.5 compatibility - closes #5516 2026-07-20 09:09:08 -07:00
FreeScout
95fcc75f90 Update example in tools.php - closes #5507 2026-07-20 03:59:54 -07:00
FreeScout
646e8266d8 Update customer in threads when merging customers - closes #5505 2026-07-19 07:36:00 -07:00
FreeScout
2fff439530 Allow to define PHP path via PHP_PATH env parameter for tools.php script - closes #5507 2026-07-19 07:09:53 -07:00
FreeScout
8f2a7f2703 Change customer when reply to conversation from completely new email is received - GHSA-j49f-9g94-3wh4 2026-07-19 01:29:10 -07:00
FreeScout
83030464ee Restrict access to Assigned Conversations option in user profile - GHSA-m4hc-rc98-38jc 2026-07-18 23:41:41 -07:00
FreeScout
f0f8fd5625 Check customer visibility in load_customer_info ajax - GHSA-cr68-27qv-p5m4 2026-07-18 23:27:12 -07:00
FreeScout
609f7be8d2 v1.8.230 2026-07-18 09:23:16 -07:00
FreeScout
1b50b24c62 Fix separating replies from Outlook - closes #5504 2026-07-16 09:50:56 -07:00
FreeScout
41fd15bb68 Improve showing bell notifications - closes #5491 2026-07-14 07:16:10 -07:00
FreeScout
6a45fff348 Fix real time web notifications - closes #5499 2026-07-13 21:33:24 -07:00
FreeScout
3f508d41a0 Send CSP header in /ajax-html/ URLs - GHSA-2g42-f97q-973x 2026-07-11 11:20:22 -07:00
FreeScout
a4c587cf1f Improve remote URL sanitizing - GHSA-6w8v-qp43-vg2h 2026-07-11 11:17:52 -07:00
FreeScout
09799fd4bc Improve remote URL sanitizing - GHSA-6w8v-qp43-vg2h 2026-07-11 04:23:01 -07:00
FreeScout
154b513af8 Enable Core.EscapeNonASCIICharacters in Purifier - closes #5481 2026-07-08 01:53:52 -07:00
FreeScout
2f6fbd76dd Save conversation subject when pressing Enter in the subject fieldt #5492 from RhinoBag/fix-subject-save-on-enter
Save conversation subject when pressing Enter in the subject field
2026-07-07 08:47:40 +03:00
FreeScout
39bc5401ef Improve showing bell notifications - closes #5491 2026-07-06 22:43:46 -07:00
Jörn Bungartz
c98b075fca Save conversation subject when pressing Enter in the subject field
Previously the inline subject editor could only be saved by clicking the
check button. Pressing Enter did nothing (the input is not inside a form),
so users frequently thought the new subject was saved when it was not,
leading to replies going out with the old subject.

Bind Enter on #conv-subj-value to trigger the existing save button, matching
the Enter-to-submit behaviour already used for the reply body.
2026-07-06 14:20:04 -06:00
FreeScout
5a34f2a312 Do not send notifications to users when customer replies to Spam conversation 2026-07-05 04:52:59 -07:00
FreeScout
f454d3f445 v1.8.229 2026-07-04 07:46:29 -07:00
FreeScout
72a8137b03 Fix Undefined variable $addressString - closes #5487 2026-07-04 07:42:05 -07:00
FreeScout
b13ec05c2a v1.8.228 2026-07-04 07:30:04 -07:00
FreeScout
9406e102ef Fix Undefined variable $ensureAddressesAreSafe - closes #5487 2026-07-04 07:29:15 -07:00
FreeScout
41b4441ab5 Adjust Modules page 2026-07-04 07:25:41 -07:00
FreeScout
85a5dbd38e Fix Undefined array key 10004 on outbound Guzzle request without a proxy - closes #5486 2026-07-04 07:04:31 -07:00
FreeScout
1704473a77 v1.8.227 2026-07-04 02:53:57 -07:00
FreeScout
f4dd553530 Fix proxiying requests with Guzzle 2026-07-04 02:50:42 -07:00
FreeScout
084cb6981b laravel/framework: Validate against line breaks in emails - GHSA-5vg9-5847-vvmq 2026-07-03 23:26:34 -07:00
FreeScout
06fa6b2940 Show proper error message when deactivating invalid license key 2026-07-03 11:39:06 -07:00
FreeScout
5f2925efd0 symfony/routing: Fix dot-segment encoding for chained "../" and "./" in generated URLs - GHSA-h5x3-xfc9-m39h 2026-07-03 11:35:00 -07:00
FreeScout
385a55b4b7 guzzlehttp/guzzle: Reject HTTPS proxies when libcurl lacks HTTPS-proxy suppor - GHSA-wpwq-4j6v-78m3 2026-07-03 11:26:35 -07:00
FreeScout
637fce7dbe guzzlehttp/guzzle: Reject HTTPS proxies when libcurl lacks HTTPS-proxy suppor - GHSA-wpwq-4j6v-78m3 2026-07-03 11:25:21 -07:00
FreeScout
e188765c21 guzzlehttp/guzzle: Reject dot-only cookie domains - GHSA-cwxw-98qj-8qjx 2026-07-03 11:00:39 -07:00
FreeScout
5b5ce88a90 guzzlehttp/psr7: Reject malformed Host values when constructing request URIs - GHSA-34xg-wgjx-8xph 2026-07-02 10:50:04 -07:00
FreeScout
7337879a65 guzzlehttp/psr7: Reject control and whitespace characters in URI host components - GHSA-hq7v-mx3g-29hw 2026-07-02 07:39:27 -07:00
FreeScout
adec0de39a guzzlehttp/psr7: Mitigate CRLF Injection in HTTP Start-Line Serialization - GHSA-vm85-hxw5-5432 2026-07-02 06:57:55 -07:00
FreeScout
7c485c96be Fix permissions check when merging conversations - GHSA-v8vc-cmf9-gg2c 2026-06-28 02:46:54 -07:00
FreeScout
15c5aaaa15 v1.8.226 2026-06-27 02:40:52 -07:00
FreeScout
29c6c2884a Fix @fwd feature for Outlook emails - closes #5480 2026-06-25 23:29:38 -07:00
FreeScout
30ba029242 Fix linkifying URLs ending with > symbol - closes #5423 2026-06-25 03:48:38 -07:00
FreeScout
00f50ac6ca Allow to create users without last name - closes #4184 2026-06-24 23:49:09 -07:00
FreeScout
e7008fa267 On fetching preserve top-posted reply content before nested <html> block (Yahoo/Android) - closes #5409 2026-06-24 23:28:55 -07:00
FreeScout
460bc8f91b Send reply on CMD+Enter - closes #5476 2026-06-24 11:08:24 -07:00
FreeScout
caa5aac1c3 Fix: customer Waiting Since shows time of last customer message instead of first - closes #5475 2026-06-24 10:43:27 -07:00
FreeScout
4e197d8ce9 Check access permissions when searching conversations by number - GHSA-wqq7-4q7m-273v 2026-06-24 03:38:12 -07:00
FreeScout
b7082a8867 Prevent sending blank email when Summernote leaves empty HTML tags - closes #5478 2026-06-24 03:30:32 -07:00
FreeScout
e47a54a5af Improve RealtimeConvNewThread event access check - GHSA-w668-wq26-6c94 2026-06-22 22:43:21 -07:00
FreeScout
1f4deee636 Fix access check in realtime polycast events - GHSA-gh3r-jh6q-wrvj 2026-06-19 10:56:40 -07:00
FreeScout
656cb2349c Escape channel name in LIKE query in PolycastServiceProvider.php - GHSA-gh3r-jh6q-wrvj 2026-06-18 22:34:03 -07:00
FreeScout
38d49720e3 Fix Could not scan for classes error - closes #5469 2026-06-17 21:52:59 -07:00
FreeScout
9ad0db4947 Fix 500 in ConversationsController::view when the user has no Mine folder - closes #5466 2026-06-15 21:24:58 -07:00
FreeScout
5fe3f44f09 Fix json_decode() error in User::hasManageMailboxPermission() - closes #5465 2026-06-15 21:20:15 -07:00
FreeScout
73a7e1f9db Fetching: paginate by SEARCH count so the newest emails are no longer dropped - closes #5462 2026-06-12 22:00:45 -07:00
FreeScout
193adb7e11 v1.8.225 2026-06-12 21:51:48 -07:00
FreeScout
3d593022fe Update SECURITY.md 2026-06-11 22:44:41 -07:00
FreeScout
7f6a5042c1 Fix saving mailbox signature by non-admin users - closes #5443 2026-06-11 21:58:58 -07:00
FreeScout
315ad05f73 Improve Helper::stripDangerousTags() to strip nested tags - GHSA-jpq8-j69f-mj98 2026-06-08 21:56:44 -07:00
FreeScout
3193add63e Move option to UI: User can see only assigned conversations - closes #701 2026-06-06 20:58:51 -07:00
FreeScout
4503d750cb Move option to UI: User can see only assigned conversations - closes #701 2026-06-06 20:57:37 -07:00
FreeScout
2a804038ff Hide folders from Log Viewer 2026-06-06 20:35:14 -07:00
FreeScout
2cd2e66b77 Fix path traversal in Log Viewer - GHSA-9ph7-f3hc-95gg 2026-06-06 20:33:08 -07:00
FreeScout
47da5838f7 Upgrade symfony/polyfill-intl-idn to 1.38.1 - CVE-2026-46644 2026-06-06 00:06:17 -07:00
FreeScout
e2b4151e78 Patch symfony/routing - CVE-2026-45065 2026-06-06 00:03:14 -07:00
FreeScout
cd9e8fea9c Fix color of texts in logs table - closes #5442 2026-06-05 22:53:41 -07:00
FreeScout
e903accecd Fix formatting in tools.php 2026-06-05 22:52:36 -07:00
FreeScout
8bb0594e95 Improve tools.php security - GHSA-w2p9-3666-vw9j 2026-06-05 22:24:46 -07:00
FreeScout
8f7400492a v1.8.224 2026-06-05 22:12:49 -07:00
FreeScout
135d2d6d0c Enable browser check 2026-06-03 21:44:58 -07:00
FreeScout
5ec9169044 Improve sanitizing customer Websites field 2026-06-03 21:28:42 -07:00
FreeScout
5e664f2e25 Restrict .pht files upload - GHSA-27vp-fpg8-j8wv 2026-06-02 22:41:00 -07:00
FreeScout
c7e21d9abe Improve downloading log files logic - GHSA-858x-8f77-9vc5 2026-06-02 10:12:52 -07:00
FreeScout
0aab1d715f Fix user-setup empty invite_hash issue - GHSA-jqj5-r72v-v29g 2026-06-01 22:40:08 -07:00
FreeScout
d3c3148945 Add throttling to file upload routes - GHSA-ph4f-2jhx-q76w 2026-05-30 22:34:26 -07:00
FreeScout
fed0ecb533 v1.8.223 2026-05-30 04:33:27 -07:00
FreeScout
b7b4ce4069 Fix fetching message sent to multiple mailboxes from own mailbox - closes #5434 2026-05-29 04:54:25 -07:00
FreeScout
beef6af9b5 Fix prototype pollution in getQueryParam - GHSA-w5fc-8pp3-f755 2026-05-29 04:52:17 -07:00
FreeScout
afa0cb6ba5 Fix preg_replace_callback() error in Html2Text - closes #5433 2026-05-28 04:23:43 -07:00
FreeScout
26ff8c73ab Fix signature when moving conversation between mailboxes - closes #5419 2026-05-28 00:11:10 -07:00
FreeScout
30c0f68c97 Improve open tracking hash - closes #5431 2026-05-27 10:42:58 -07:00
FreeScout
875ab40739 Disable backward compatibility for old Message-IDs format on fetching - GHSA-8vm3-wwq4-ggfx 2026-05-25 21:22:05 -07:00
FreeScout
e39fa1299f v1.8.222 2026-05-25 01:39:04 -07:00
FreeScout
e2dcf6dc7e Send Auto Reply from the alias the customer emailed - closes #5428 2026-05-24 00:37:41 -07:00
FreeScout
28621dbfcf Update German translation 2026-05-23 07:35:26 -07:00
FreeScout
6ba03d32a0 Fix undefined signature_mailbox error on sending - closes #5427 2026-05-23 03:04:39 -07:00
FreeScout
2fda21b54e v1.8.221 2026-05-22 22:35:46 -07:00
FreeScout
fc8f50c973 Show detailed error when uploading attachments - closes #5426 2026-05-21 22:56:07 -07:00
FreeScout
15db137829 Optimize Helper::stripDangerousTags() to avoid pcre.backtrack_limit hit - closes #5424 2026-05-21 11:21:10 -07:00
FreeScout
4a678b8309 Update zh-TW translation - closes #5421 2026-05-20 21:25:52 -07:00
FreeScout
1261b8b84c Fix signature when moving conversation between mailboxes - closes #5419 2026-05-20 03:34:54 -07:00
FreeScout
ae68e194b1 Improve permissions check when editing messages - GHSA-3w38-h42v-3h6w 2026-05-18 06:36:15 -07:00
FreeScout
5dab098380 Improve permissions check when deleting notes - GHSA-9vx8-gx3p-9mh6 2026-05-17 03:55:38 -07:00
FreeScout
215241ee2e Deprecate links to attachments without token - GHSA-wg74-ww4w-2qpc 2026-05-17 02:22:11 -07:00
FreeScout
f5c566c2e8 Update module activation logic 2026-05-17 01:56:43 -07:00
FreeScout
3e216820ba v1.8.220 2026-05-16 21:48:23 -07:00
FreeScout
4008a22ef5 Fix checking trusted hosts during installation 2026-05-11 05:08:29 -07:00
FreeScout
fcea5716e5 Activate module after activating license 2026-05-10 04:23:00 -07:00
FreeScout
b455972062 Clear JS and CSS builds when clearing cache 2026-05-09 22:42:32 -07:00
FreeScout
a958553b72 Add configurable threshold to suppress transient fetch errors in Logs Monitoring - closes #5399 2026-05-09 05:36:27 -07:00
FreeScout
d902f19038 Check hash in replies to user email notifications - GHSA-6r38-6mcf-2ww3 2026-05-08 12:00:54 -07:00
FreeScout
8e5e73b4ce v1.8.219 2026-05-08 10:29:34 -07:00
FreeScout
48f05f34e6 Fix PHP check in tools.php 2026-05-06 21:25:24 -07:00
FreeScout
ab8d681219 Fix error tracking on creating user profile from invite link - closes #5390 2026-05-06 01:34:00 -07:00
FreeScout
68746b8c17 Add Russian lang files 2026-05-02 23:09:50 -07:00
FreeScout
369eb9217a Add throttle to the Forgot Password form and return identical response regardless of whether the email exists - GHSA-jvmv-2qcp-7855 2026-05-02 23:06:28 -07:00
FreeScout
5a5a48e32c Add hash to open tracking URL - GHSA-qjr9-6v9q-3r72 2026-05-02 11:23:11 -07:00
FreeScout
79d5955ab1 Update SECURITY.md 2026-05-02 10:30:17 -07:00
FreeScout
d7fb065a5f Update German translation 2026-05-01 21:43:27 -07:00
FreeScout
956f631661 Add table prefix to raw DB queries - closes #5385 2026-05-01 03:51:05 -07:00
FreeScout
d5b63611ad Show message in the interface when browser does not support CSP 2026-04-28 07:48:55 -07:00
FreeScout
296a4c82a1 Fix error on PHP 7.1 - closes #5377 2026-04-27 03:50:34 -07:00
FreeScout
285ca977fa Add Catalan tranlation - closes #5376 2026-04-25 21:52:31 -07:00
FreeScout
a0b3888b18 Update German translation 2026-04-25 07:28:28 -07:00
FreeScout
f58b6d8bda v1.8.218 2026-04-24 21:55:09 -07:00
FreeScout
a7a2fe91e6 Add comment to APP_PROXY in .env.example 2026-04-24 02:42:52 -07:00
FreeScout
b0bf40295b Fix fetching emails into multiple mailboxes - closes #5368 2026-04-23 22:59:32 -07:00
FreeScout
da9bbfde8f Add APP_REMOTE_HOST_WHITE_LIST to .env.example 2026-04-23 21:24:09 -07:00
FreeScout
65219613b9 Deprecate use_new_pop3_lib config parameter 2026-04-23 05:34:22 -07:00
FreeScout
45356bfbdf Fix linking messages into conversations - closes #5372 2026-04-22 22:59:27 -07:00
FreeScout
c837ad77e5 Do not allow to merge convesation with self 2026-04-22 21:58:27 -07:00
FreeScout
164349549d Allow to use cidr in APP_REMOTE_HOST_WHITE_LIST - closes #5363 2026-04-22 21:23:17 -07:00
FreeScout
0c75dfde32 Allow to use cidr in APP_REMOTE_HOST_WHITE_LIST - closes #5363 2026-04-22 11:05:17 -07:00
FreeScout
87dba690ef Add upload_mode parameter to Helper::sanitizeUploadedFileName() 2026-04-22 11:00:27 -07:00
FreeScout
61adcbd890 Fix Helper::linkify() for links - closes #5367 2026-04-21 06:52:22 -07:00
FreeScout
190cdea748 Adjust waiting since block in Dashboard 2026-04-21 06:47:37 -07:00
FreeScout
53ce4523a4 Fix Helper::linkify() for emails - closes #5362 2026-04-20 10:58:38 -07:00
FreeScout
291ff9e306 Add indexes to tables - closes #5328 2026-04-20 10:26:36 -07:00
FreeScout
855b573672 Update SECURITY.md 2026-04-20 07:38:38 -07:00
FreeScout
3dcdbb85d6 Adjust tools.php 2026-04-20 06:05:38 -07:00
FreeScout
d8ec060a9d Require DB Password and check PHP Path direcotory in tools.php - GHSA-jx2w-fhmw-rg39 2026-04-20 06:03:32 -07:00
FreeScout
4fc258f77b Do not log Untrusted host error - closes #5361 2026-04-20 03:16:34 -07:00
FreeScout
25edcebd77 Patch PHPUnit - GHSA-qrr6-mg7r-m243 2026-04-19 23:58:03 -07:00
FreeScout
deb6788773 Fix decoding ISO-2022-JP emails - closes #5356 2026-04-19 21:55:46 -07:00
FreeScout
b729fcb2e1 v1.8.217 2026-04-19 07:19:54 -07:00
FreeScout
1371340962 Improve encryption and decryption with password via Helper 2026-04-18 21:48:32 -07:00
FreeScout
0dbf2e1665 Make user invite link expirable - GHSA-hqff-cwx7-3jpm 2026-04-18 05:18:06 -07:00
FreeScout
2293911718 Fix permissions check for user Notifications settings - GHSA-f489-qxv6-gvgg 2026-04-18 03:44:42 -07:00
FreeScout
864ad08820 Improve sanitizing Auto Reply message - GHSA-q3fh-rj9h-jfrc 2026-04-18 03:04:25 -07:00
FreeScout
270bd58f61 Fix redirect check in Helper::sanitizeRemoteUrl() - GHSA-22wf-848c-c856 2026-04-18 02:52:08 -07:00
FreeScout
60c01c3707 Fix Zipper: Path traversal detected error - closes #5354 2026-04-16 11:00:29 -07:00
FreeScout
17d31b62ad Fix On-Off switch on RTL - closes #5352 2026-04-16 03:32:52 -07:00
FreeScout
66e5ac8e9d v1.8.216 2026-04-16 03:18:30 -07:00
FreeScout
43f6e154a5 Fix error in MailHelper::isGeneratedMessageId() - closes #5351 2026-04-15 23:22:46 -07:00
FreeScout
d61b74c306 Fix fetching into multiple mailboxes - closes #5350 2026-04-15 21:14:32 -07:00
FreeScout
95882ea8f1 PHP 8.5 compatibility 2026-04-15 07:45:18 -07:00
FreeScout
a7d678ac21 Fix mute icon for non admin users in Dashboard - closes #5348 2026-04-15 07:42:12 -07:00
FreeScout
4879a34b3d Fix search.title hook - closes #5347 2026-04-15 05:18:35 -07:00
FreeScout
01fa0955d4 Add URL to the error log message in CheckBrowser middleware 2026-04-15 03:22:05 -07:00
FreeScout
5f34e80a22 Fix OAuth disconnect link on PHP 7.1 - closes #5345 2026-04-15 00:57:59 -07:00
FreeScout
94485d86bb Fix moving conversations - closes #5343 2026-04-14 23:20:16 -07:00
FreeScout
ce20dfa787 Fix null parameter error in CheckBrowser - closes #5342 2026-04-14 10:56:07 -07:00
FreeScout
c703f38f6c v1.8.215 2026-04-14 07:44:45 -07:00
FreeScout
8a887502e1 Fix saving Sending and Fetching passwords - closes #5339 2026-04-14 07:43:41 -07:00
FreeScout
3368437e4e Fix 403 error in open tracking pixel - closes #5334 2026-04-14 07:29:56 -07:00
FreeScout
59cababa70 Fix error on creating user - closes #5337 2026-04-14 05:07:52 -07:00
FreeScout
b4d2846a3a Change Helper::stripTags() back to strip_tags() in some places 2026-04-14 03:59:19 -07:00
FreeScout
e45213e537 Add embed and appliet to the list of dangerous tags in Helper::stripDangerousTags() 2026-04-14 03:48:19 -07:00
FreeScout
c35fc2a502 Make conversation Unassigned when moving if assignee does not have access to the target mailbox - closes #5333 2026-04-13 21:58:24 -07:00
FreeScout
6583d6f5a5 For Assigned-only users show only assigned conversations in Search 2026-04-13 21:44:30 -07:00
FreeScout
25f92844c2 Create Open Middleware group 2026-04-13 21:26:35 -07:00
FreeScout
f4590b8279 Fix compact() Undefined variable operator - closes #5308 2026-04-13 21:18:48 -07:00
FreeScout
cdadaf621b Check permission when Assigned-only user is editing customer message 2026-04-13 11:37:54 -07:00
FreeScout
414878eb79 Check permission for Assigned-only users when editing drafts 2026-04-13 11:21:27 -07:00
FreeScout
aaf334e9bb Rename permission: Users are allowed to edit own notes/replies 2026-04-13 11:01:17 -07:00
FreeScout
f35fddb0cc Create Open Middleware group 2026-04-13 10:09:43 -07:00
FreeScout
fb130de64e Check permissions when settings chat_start_new for mailbox 2026-04-12 23:14:15 -07:00
FreeScout
5f182818e2 Sanitize attachments_to_remove when deleting attachments 2026-04-12 22:54:41 -07:00
FreeScout
eb397efae2 Add csrf_token to OAuth Disconnect link 2026-04-12 21:33:11 -07:00
FreeScout
14f17a5cd2 Check file paths in Zipper before extracting 2026-04-12 11:04:30 -07:00
FreeScout
d0713d5a67 v1.8.214 2026-04-12 08:53:24 -07:00
FreeScout
b33d1e56a8 Fix Browser check - closes #5331 2026-04-12 08:51:09 -07:00
FreeScout
c779afdda8 Check thread created_by_user_id when Undoing sending 2026-04-11 22:58:25 -07:00
FreeScout
83eea1ca47 Check customer visibility when creating Phone conversation 2026-04-11 03:40:30 -07:00
FreeScout
d459a3b0cf Check customer visibility when creating a customer within Change Customer dialog 2026-04-10 10:29:48 -07:00
FreeScout
b3d7611e6e Check if customer is accessible when changing conversation customer 2026-04-10 10:15:27 -07:00
FreeScout
2e2fe37111 Do not allow to move emails from customers from inaccessible mailboxes 2026-04-10 09:54:03 -07:00
FreeScout
c2680bd9ec Improve joining customer messages into conversations on Fetching - closes #5308 2026-04-10 08:45:38 -07:00
FreeScout
88baece2a4 v1.8.213 2026-04-09 10:29:44 -07:00
FreeScout
265379b3ae Escape values in Helper::linkify() 2026-04-09 04:20:21 -07:00
FreeScout
39182e106a Send emails to all recipients when forwarding to multiple recipients - closes #5322 2026-04-09 02:36:09 -07:00
FreeScout
6f6419aafd Improve joining customer messages into conversations on Fetching - closes #5308 2026-04-08 23:00:05 -07:00
FreeScout
efe82e31b4 Validate host in mail Fetching and Sending settings 2026-04-08 07:41:53 -07:00
FreeScout
8babf94028 PHP 8.4 compatibility 2026-04-08 05:07:29 -07:00
FreeScout
1d83e1cffb Add CheckBrowser middleware ensuring that browser supports CSP 2026-04-08 03:24:41 -07:00
FreeScout
b2a1be6b3c Require mod_headers if Apache is used 2026-04-08 01:11:58 -07:00
FreeScout
f45b9105d4 Limit fields which can be populated on mailbox Fetching and Sending settings pages 2026-04-08 00:43:57 -07:00
FreeScout
97511c182b Escape customer name in the reply_fancy email template 2026-04-08 00:11:19 -07:00
FreeScout
b1d6c2c601 Improve Helper::getWebCronHash() 2026-04-07 23:04:36 -07:00
FreeScout
af3f1e2889 Improve joining customer messages into conversations on Fetching - closes #5308 2026-04-07 21:39:13 -07:00
FreeScout
a894ae1312 Apply safe_raw_html() function 2026-04-07 06:32:50 -07:00
FreeScout
ef5fbe30f4 Helper::stripInvalidUnicodeChars() function 2026-04-06 06:38:49 -07:00
FreeScout
1eb0469bd0 Check attachment name when downloading attachments 2026-04-06 06:16:27 -07:00
FreeScout
269e5ad424 Replace strip_tags() with Helper:stripTags() 2026-04-06 06:10:33 -07:00
FreeScout
5aa8d63321 Strip also style tags in Helper::stripDangerousTags() 2026-04-05 21:45:03 -07:00
FreeScout
e009c4675b Escape user name in flash message when deleting user 2026-04-05 21:19:35 -07:00
FreeScout
9131b16f80 Strip tags from name when creating user 2026-04-05 21:02:52 -07:00
FreeScout
c921033e8c Add mailbox.sidebar.buttons hook - closes #5316 2026-04-04 20:24:27 -07:00
FreeScout
dc886e7bb8 Fix Call to a member function close() on string in Zipper 2026-04-04 09:50:41 -07:00
FreeScout
7c73d5f168 Add allow_redirects to requests in WpApi 2026-04-04 09:43:34 -07:00
FreeScout
f35b4249c7 Take into account limit_user_customer_visibility parameter in load_customer_infoload_customer_info Ajax action 2026-04-04 04:49:33 -07:00
FreeScout
dbdf8f2260 Change attachment token generation algorythm 2026-04-03 22:13:36 -07:00
FreeScout
1de4e9b37a v1.8.212 2026-04-03 21:53:20 -07:00
FreeScout
726ef147eb Check if conversation IDs match when marking thread as read 2026-04-03 20:22:51 -07:00
FreeScout
b395a11791 Take limit_user_customer_visibility parameter into account when merging customers 2026-04-02 07:47:22 -07:00
FreeScout
2353c0bd1a Take limit_user_customer_visibility parameter into account when merging customers 2026-04-01 21:13:28 -07:00
FreeScout
b341dd86f7 Fix ncomplete object error on Status page - closes #5307 2026-04-01 07:32:09 -07:00
FreeScout
7e29e2463f Fix Passing null to parameter error in Html2Text - closes #5306 2026-03-31 20:50:16 -07:00
FreeScout
a8f3e558d7 Fix fetching emails with deeply nested HTML - closes #5304 2026-03-30 08:30:34 -07:00
FreeScout
55ff20a5d5 Update API URL 2026-03-28 23:45:30 -07:00
FreeScout
afac233d5a v1.8.211 2026-03-27 22:50:43 -07:00
FreeScout
999cb49b58 Search conversation by correct number field - closes #5298 2026-03-26 23:54:23 -07:00
FreeScout
2810916a88 Add TrustHosts middleware 2026-03-26 10:32:36 -07:00
FreeScout
023882f9d9 Fix fetching emails containing null bytes in body - closes #5292 2026-03-26 06:30:32 -07:00
FreeScout
889d75c8e3 Add TrustHosts middleware 2026-03-25 23:15:34 -07:00
FreeScout
de69907c9d Add Helper::isSafePassword() function 2026-03-24 23:40:29 -07:00
FreeScout
b9ee220984 Allow to search conversations by number - closes #5251 2026-03-24 10:17:10 -07:00
FreeScout
4cea7689a4 Remove IMAP extension from list of required PHP extensions 2026-03-24 07:45:42 -07:00
FreeScout
ca6d5bb572 Fix Helper::checkIpByMask() function 2026-03-24 07:40:06 -07:00
FreeScout
e489c2cc1d v1.8.210 2026-03-22 21:55:18 -07:00
FreeScout
a09a0f901d Add zh-TW language - closes #5285 2026-03-19 05:00:46 -07:00
FreeScout
729ee307e9 Fix playing audio attachments - closes #5281 2026-03-17 10:14:40 -07:00
FreeScout
486a2b9c15 Fix saving empty encrypted ENV values to via settings 2026-03-16 08:53:21 -07:00
FreeScout
b0b4e46139 Fix previewing attachments - closes #5275 2026-03-14 23:48:36 -07:00
FreeScout
0565ba97b7 Fix accessibility issues with magic checkboxes used in the conversations table - closes #4968 2026-03-14 21:53:26 -07:00
FreeScout
9ee5066922 Fix Undefined constant PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT - closes #5273 2026-03-14 21:28:43 -07:00
FreeScout
ebe1913e12 v1.8.209 2026-03-13 21:59:10 -07:00
FreeScout
d5c34fb3c7 fix(webklex): Add Header.has(), called by Part.isAttachment() #5267 from boite/fix-missing-has
fix(webklex): Add Header.has(), called by Part.isAttachment()
2026-03-13 05:18:49 +01:00
boite
2b2d7b1c29 fix(webklex): Add Header.has(), called by Part.isAttachment()
This method does not exist and would result in a MethodNotFoundException
thrown by __call().
2026-03-12 22:56:08 +00:00
FreeScout
a664ac58a7 Fix iconv_mime_decode(): Detected an illegal character in input string - closes #5265 2026-03-12 11:12:24 -07:00
FreeScout
3329379db3 Include sanitized thread bodies in replies and email notifications 2026-03-11 00:19:12 -07:00
FreeScout
c1d2a1cbe5 Add CSP header when showing attachments 2026-03-10 21:53:19 -07:00
FreeScout
69d0504572 Add non_viewable_mime_types config parameter 2026-03-10 02:47:35 -07:00
FreeScout
cb86188457 Improve sanitizing SVG images containing comments 2026-03-09 23:26:11 -07:00
FreeScout
8bb8ea4140 Encrypt modules license keys 2026-03-09 23:05:31 -07:00
FreeScout
8045aaae95 Encrypt modules license keys 2026-03-09 23:02:04 -07:00
FreeScout
f5c3fe5610 Encrypt modules license keys 2026-03-08 07:34:26 -07:00
FreeScout
2a13430265 Encrypt OAuth tokens for IMAP and SMTP 2026-03-08 06:41:01 -07:00
FreeScout
1e319c91e1 Improve Helper::sanitizeRemoteUrl() function 2026-03-07 01:34:14 -08:00
FreeScout
cb6ea3284f Hide IMAP password in fetch command debug output 2026-03-05 23:57:25 -08:00
FreeScout
44c4cd0da8 Offer TLS encryption in Fetching and Sending settings by default 2026-03-05 22:15:01 -08:00
FreeScout
996a7f9633 Check access to mailbox when user edits customer thread 2026-03-05 21:40:51 -08:00
FreeScout
16e78f5b3e Check customer visibility on Customer Conversations page 2026-03-05 21:36:26 -08:00
FreeScout
b28d007210 Update French translation #5260 from JuliaLblnd/master
Update French translation
2026-03-06 05:58:58 +01:00
FreeScout
f13f07b5fe v1.8.208 2026-03-05 20:39:37 -08:00
Julia
e00480be02 Update French translation 2026-03-05 12:03:04 +01:00
FreeScout
5c2b2c7a51 Check access to mailbox when emptying folder 2026-03-04 20:55:24 -08:00
FreeScout
f0104169dc Adjust Helper::shellExec() function - closes #5250 2026-03-04 20:46:09 -08:00
FreeScout
411d8e7f91 Update SECURITY.md 2026-03-04 20:18:03 -08:00
FreeScout
5e08455cea Reduced Cc and Bcc in languages where the text does not fit nicely #5253 from il2020/master
Reduced Cc and Bcc in languages where the text does not fit nicely
2026-03-04 13:44:36 +01:00
il2020
08a6a53bdc Reduced Cc and Bcc in languages where the text does not fit nicely
https://github.com/freescout-help-desk/freescout/issues/5247
2026-03-04 12:37:55 +00:00
FreeScout
c7798de0e2 Update SECURITY.md 2026-03-04 04:16:43 -08:00
FreeScout
bd498e0a24 Disable blank issue in the repo 2026-03-04 04:12:45 -08:00
FreeScout
7cbbd5d187 Show errors on Status page when shell_exec() funciton can not be executed - closes #5250 2026-03-03 22:01:10 -08:00
FreeScout
f691461318 Do not show Cc and Bcc fields by default - closes #5247 2026-03-03 06:44:31 -08:00
FreeScout
a0d302dd0a Set curl_ssl_verifypeer parameter to true by default 2026-03-02 23:38:10 -08:00
FreeScout
b215d882d1 Improve sanitizing uploaded PDF files 2026-03-02 09:58:46 -08:00
FreeScout
19b94fcbb1 Fix incomplete object error on Status page - closes #5246 2026-03-02 05:02:47 -08:00
FreeScout
e76b683d85 Disable loading local config files in psy/psysh 2026-03-01 23:19:20 -08:00
FreeScout
2919365ca0 Symfony Process: Fix escaping for MSYS on Windows 2026-03-01 22:57:02 -08:00
FreeScout
c8621813b8 Fix unsafe unserialize() in phpunit 2026-03-01 22:31:31 -08:00
FreeScout
4b67a88227 Fix PATH_INFO issue in symfony/http-foundation 2026-03-01 22:09:05 -08:00
FreeScout
594f5b93af Update enshrined/svg-sanitize to v0.22.0 2026-03-01 22:04:23 -08:00
FreeScout
be848b8f63 Composer update 2026-03-01 22:02:59 -08:00
FreeScout
c77b77e774 Message-ID and Apache SpamAssassin - closes #5245 2026-03-01 21:36:29 -08:00
FreeScout
5d1b5e7c87 Message-ID and Apache SpamAssassin - closes #5245 2026-03-01 09:28:47 -08:00
FreeScout
a894d8c4f2 Fix permissions check when Following/Unfollowing conversation 2026-02-27 20:49:39 -08:00
FreeScout
aaa876a6d5 Adjust phpcs 2026-02-27 04:04:37 -08:00
FreeScout
6258a2d585 Adjust phpcs 2026-02-27 03:58:50 -08:00
FreeScout
581d351ba6 Fix phpcs notices 2026-02-27 03:53:37 -08:00
FreeScout
e8dfd0fe68 Adjust phpcs 2026-02-27 00:35:42 -08:00
FreeScout
6f3dd89f75 v1.8.207 2026-02-26 22:14:22 -08:00
FreeScout
fae6417c40 Update phpcs 2026-02-26 22:11:39 -08:00
FreeScout
f7bc16c56a Perform sanitizing of the file name first in Helper::sanitizeUploadedFileName() 2026-02-26 09:25:15 -08:00
FreeScout
7c9f9635c5 PHP 8.5 compatibility - closes #5240 2026-02-26 06:40:26 -08:00
FreeScout
ee9cda1f0c PHP 8.5 compatibility - closes #5238 2026-02-26 05:34:54 -08:00
FreeScout
5a235a96a5 Goole Workspace OAuth - closes #5241 2026-02-26 02:32:31 -08:00
FreeScout
451ef5cf27 Goole Workspace OAuth - closes #5241 2026-02-26 00:26:09 -08:00
FreeScout
017a958b0e Fix Undefined array key error on sending reply to Phone conversation - closes #5236 2026-02-24 06:38:46 -08:00
FreeScout
a53818c147 Fix parsing email part Content-Type ending with semicolon 2026-02-23 20:44:39 -08:00
FreeScout
026989d523 Fix parsing email part Content-Type ending with semicolon 2026-02-23 10:02:29 -08:00
FreeScout
1d78708570 Add PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT to DB config - closes #5230 2026-02-22 21:21:17 -08:00
FreeScout
893c878c37 Check customer visibility when merging customers 2026-02-22 07:58:13 -08:00
FreeScout
accd83b835 Check user access to mailbox in empty_folder ajax action 2026-02-22 07:45:07 -08:00
FreeScout
b20a586989 Update tests matrix 2026-02-22 01:36:21 -08:00
FreeScout
3cd905274a Update tests matrix 2026-02-22 01:34:40 -08:00
FreeScout
d5708283d5 Update ConversationChangeCustomerTest 2026-02-22 01:26:53 -08:00
FreeScout
b8232fad6a Skip ConversationChangeCustomerTest on PHP < 8.4 2026-02-22 01:22:48 -08:00
FreeScout
6aca3bdda2 Skip ConversationChangeCustomerTest on PHP < 8.3 2026-02-22 01:12:11 -08:00
FreeScout
619db43999 Fix notices in phpunit 2026-02-22 01:03:32 -08:00
FreeScout
2101f72f4e PHP 8.3 compatibility 2026-02-22 00:51:51 -08:00
FreeScout
417e25dd44 Fix checking user authorization when changing conversation customer - closes #5232 2026-02-22 00:34:18 -08:00
FreeScout
3ef7bf2af8 Feature testing 2026-02-22 00:32:27 -08:00
FreeScout
5ab9cace19 Fix array_filter() in database config - closes #5230 2026-02-21 03:04:38 -08:00
FreeScout
5710da0ccb v1.8.206 2026-02-21 00:58:55 -08:00
FreeScout
b134ee8939 Set allowed_classes parameter for unserialize() functions 2026-02-20 10:45:00 -08:00
FreeScout
db902c7f04 Remove role from fillable User fields 2026-02-19 23:08:02 -08:00
FreeScout
9984071e6f Extend Helper::restricted_extensions list 2026-02-19 22:47:02 -08:00
FreeScout
00d45dc954 Improve Helper::sanitizeUploadedFileName() function 2026-02-19 22:41:43 -08:00
FreeScout
004a8231f6 Improve TokenAuth middleware 2026-02-19 22:29:54 -08:00
FreeScout
1bfe3d5444 PHP 8.5 compatibility - closes #5227 2026-02-19 05:36:32 -08:00
FreeScout
587ae175c1 Update Customer addChannel() method - closes #5224 2026-02-18 22:25:26 -08:00
FreeScout
e19633ce1a v1.8.205 2026-02-14 00:23:54 -08:00
FreeScout
6e36e39187 Fix styles for folder having .alert class - closes #5216 2026-02-12 09:32:14 -08:00
FreeScout
3991b68a0e PHP 8.5 compatibility - closes #5215 2026-02-12 05:54:07 -08:00
FreeScout
b76361f27d PHP 8.4 compatibility - closes #5214 2026-02-12 05:49:01 -08:00
FreeScout
387c94333e Add csp.form_action hook - closes #5189 2026-02-11 20:40:07 -08:00
FreeScout
9760bf620e Set max letngth for conversation subject - closes #5201 2026-02-08 04:26:37 -08:00
FreeScout
931c93506a Do now show CC and BCC fields when editing chat message draft - closes #5196 2026-02-07 22:07:30 -08:00
FreeScout
b01e0f7968 Fix wrong customer name after recipient change - closes #5199 2026-02-07 21:50:42 -08:00
FreeScout
2bd7e35b57 Remove unneeded symlink 2026-02-07 20:56:51 -08:00
FreeScout
47c085df03 v1.8.204 2026-02-07 20:51:27 -08:00
FreeScout
84c96abdd4 Update German translation 2026-02-07 03:16:29 -08:00
FreeScout
3711f0d0a3 Use PHP library to fetch via POP3 2026-02-06 04:14:17 -08:00
FreeScout
625f3b623d Remove IMAP extension from required extensions list 2026-02-06 04:11:40 -08:00
FreeScout
743c3241b5 Update Slovak translation 2026-02-03 01:45:39 -08:00
FreeScout
4e8e852530 Add conversation.skip_send_reply_to_customer hook - closes #5187 2026-02-02 10:26:59 -08:00
FreeScout
3a5cb77690 Add base-uri rule to CSP tag 2026-02-02 10:02:08 -08:00
FreeScout
2338a3dddf Remove Australia/Queensland timezone - closes #5175 2026-01-26 05:46:12 -08:00
FreeScout
06f6b4f6ac v1.8.203 2026-01-24 22:09:21 -08:00
FreeScout
f79e56f7d1 Fix invalid byte sequence for encoding UTF8 on PostgreSQL- closes #5159 2026-01-22 20:41:23 -08:00
FreeScout
e560512a03 Revert "Collapse email history in Gmail - closes #5136"
This reverts commit 7770ae10d2.
2026-01-22 02:00:23 -08:00
FreeScout
4752adbf35 Show customer email in conversations list - closes #5066 2026-01-18 20:59:55 -08:00
FreeScout
3454d28bd8 Remove customer email from conversations list - closes #5066 2026-01-16 20:41:14 -08:00
FreeScout
7152ee7902 v1.8.202 2026-01-15 20:42:23 -08:00
FreeScout
cd56e5c3e0 Show scroll when there are many social profiles - closes #4644 2026-01-12 20:42:15 -08:00
FreeScout
bcee603c02 Show scroll when there are many social profiles - closes #4644 2026-01-12 20:38:08 -08:00
FreeScout
f3fb6ccb91 Show customer email in conversations list - closes #5066 2026-01-11 20:52:23 -08:00
FreeScout
c4c4263772 command.after_app_update hook - closes #5147 2026-01-11 06:46:45 -08:00
FreeScout
2c781425c8 Rename Headers tab to Email Headers - closes #5146 2026-01-10 20:59:10 -08:00
FreeScout
971a6915cc Update folders counters when deleting conversations forever 2026-01-06 02:18:21 -08:00
FreeScout
8c99abc8f4 Fix http_response_header error in Guzzle - closes #5137 2026-01-05 09:48:36 -08:00
FreeScout
e4e099d5ac v1.8.201 2026-01-05 02:40:56 -08:00
FreeScout
7770ae10d2 Collapse email history in Gmail - closes #5136 2026-01-05 02:37:12 -08:00
FreeScout
c626c77411 PHP 8.4 compatibility 2026-01-03 02:38:51 -08:00
FreeScout
19d306454e Add array_first_cond() function 2025-12-29 08:44:22 -08:00
FreeScout
be4cd23e17 PHP 8.4 compatibility - closes #5134 2025-12-29 05:22:09 -08:00
FreeScout
94ce31599a PHP 8.4 compatibility for Guzzle RequestException - closes #5133 2025-12-29 01:07:44 -08:00
FreeScout
5883cbe9a4 PHP 8.5 2025-12-20 10:22:28 -08:00
FreeScout
c46e9a0acf Send email to correct customer when customer is changed - closes #5121 2025-12-19 06:27:50 -08:00
FreeScout
d1087be216 v1.8.200 2025-12-19 03:32:54 -08:00
FreeScout
6e9132526a PHP 8.5 2025-12-19 02:28:24 -08:00
FreeScout
7cf1dad4db Fix CacheBasedSessionHandler PHP >= 8.1 - closes #5120 2025-12-17 09:46:39 -08:00
FreeScout
43576f53ca PHP 8.5 compatibility - closes #5114 2025-12-17 09:23:05 -08:00
FreeScout
061eabfadd PHP 8.5 compatibility - closes #5114 2025-12-16 10:18:25 -08:00
FreeScout
16f97bc086 Delete attachments when mailbox is deleted - closes #5108 2025-12-13 04:52:18 -08:00
FreeScout
c7bc54cf15 Sanitize email length before saving - closes #5106 2025-12-12 21:52:13 -08:00
FreeScout
e6cf128c3d Set last_reply_at when email conversation with one Note thread created - closes #5105 2025-12-12 21:44:37 -08:00
FreeScout
d9186546f6 Sanitize email length before saving - closes #5106 2025-12-11 22:57:37 -08:00
FreeScout
c632289e17 v1.8.199 2025-12-10 20:28:56 -08:00
FreeScout
97006884b5 Fix oAuth sending and Fftching - closes #5101 2025-12-10 02:52:30 -08:00
FreeScout
391a84ee06 Add mailbox.deleted hook 2025-12-09 10:09:06 -08:00
FreeScout
22495f5847 Fix oAuth sending and Fftching - closes #5101 2025-12-09 09:55:30 -08:00
FreeScout
b1d35954a6 v1.8.198 2025-12-05 21:50:58 -08:00
FreeScout
2ecdad8114 Mark IMAP extension as optional on System Status page - closes #5055 2025-12-05 21:49:46 -08:00
FreeScout
c92e38a031 Make mysql_pass more random in install.sh - closes #5098 2025-12-05 10:23:40 -08:00
FreeScout
fc127d7dd4 Add APP_REMOTE_HOST_WHITE_LIST .env parameter 2025-12-01 09:49:29 -08:00
FreeScout
6981620c15 Sort mailboxes on user Permissions page - closes #5087 2025-11-27 08:16:15 -08:00
FreeScout
4f1b0180c1 Allow to specify email address in Username field for oAuth connections - #5051 2025-11-27 00:42:06 -08:00
FreeScout
4e46be5470 Add states parameter to Conversation getThreads() function 2025-11-26 23:11:48 -08:00
FreeScout
69c58b4871 Properly close conversations on agent reply by email - closes #5078 2025-11-24 00:40:47 -08:00
FreeScout
d4e1686ea0 More detailed error message for SMTP errors 2025-11-22 10:33:11 -08:00
FreeScout
d42a4dee4a v1.8.197 2025-11-21 09:51:12 -08:00
FreeScout
62bd684642 v1.8.196 2025-11-21 09:37:19 -08:00
FreeScout
ea8505e2e5 Process customer names with comma - closes #5074 2025-11-21 02:12:24 -08:00
FreeScout
2855caeceb Add TLS v1.3 to SMTP connection - closes #5070 2025-11-19 10:08:25 -08:00
FreeScout
cc2bec702b More detailed error message for SMTP errors 2025-11-19 09:57:27 -08:00
FreeScout
7131be95fb Process situation when SMTP server does not reply after receiving mail data 2025-11-18 22:02:18 -08:00
FreeScout
229b156f7f Change smtp_timeout config parameter to 25 2025-11-18 14:18:16 -08:00
FreeScout
49309ade2d Write message to log when can not remove mutex in schedule:run 2025-11-18 10:27:25 -08:00
FreeScout
3f813a0432 Improve MSO comments processing in incoming emails - closes #5068 2025-11-18 06:34:11 -08:00
FreeScout
19e765f4b4 Add Slovenian language 2025-11-16 02:54:08 -08:00
FreeScout
b1a7daa2d5 RTL support for customer and notification emails - closes #5056 2025-11-09 02:23:28 -08:00
FreeScout
26527fa927 RTL support for emails - closes #5056 2025-11-08 04:37:21 -08:00
FreeScout
7c6d62b068 Update Hebrew translation 2025-11-07 20:38:49 -08:00
FreeScout
99e1f2a7ce v1.8.195 2025-10-31 21:36:39 -07:00
FreeScout
3ac611f4b9 APP_USE_NEW_POP3_LIB parameter - closes #5048 2025-10-31 04:38:59 -07:00
FreeScout
7d2b40c524 Update Italian translation 2025-10-31 04:24:27 -07:00
FreeScout
d0161321cf Check user alternate emails when receveing @fwd emails - closes #5047 2025-10-29 04:43:07 -07:00
FreeScout
163a7f4745 Show mailbox email instead of user email in conversation print view - closes #5043 2025-10-27 06:32:01 -07:00
FreeScout
ad06917b88 Update Hebrew translation 2025-10-23 06:03:34 -07:00
FreeScout
6c7db6c883 Add Hebrew language 2025-10-21 10:21:23 -07:00
FreeScout
dfc6b002e0 Improve prefilling new conversation body via GET parameter 2025-10-18 02:30:47 -07:00
FreeScout
3b721788f2 v1.8.194 2025-10-17 09:42:26 -07:00
FreeScout
80a93ede92 Remove csp_enabled parameter from config 2025-10-17 09:41:27 -07:00
FreeScout
037eec6bbf Update Romanian translation 2025-10-14 02:48:34 -07:00
FreeScout
057c1d3e60 Improve Helper::cspMetaTag() function 2025-10-14 02:02:31 -07:00
FreeScout
950859ccec Improve Helper::cspMetaTag() function 2025-10-12 00:26:27 -07:00
FreeScout
a9ac255bc2 Fix searching customers via ajax on PostgreSQL - closes #5032 2025-10-11 04:49:07 -07:00
FreeScout
facbe21990 Add __j() and __h() to translation manager config 2025-10-11 04:23:54 -07:00
FreeScout
0ab988600d Update Polish translation 2025-10-07 22:57:57 -07:00
FreeScout
4e106cb0a7 Do not change conversation status to Active when reply to Spam conversation arrives - closes #5005 2025-10-07 03:14:26 -07:00
FreeScout
44d7a28616 Filter for web_notification person #5027 from Lars-/master
Filter for web_notification person
2025-10-07 06:53:40 +02:00
FreeScout
e1c596a729 v1.8.193 2025-10-06 21:45:20 -07:00
Lars Jansen
6ae2b0fdbe feat: filter for web_notification person 2025-10-06 14:13:51 +02:00
FreeScout
6890cc38c9 Fix sending emails when some mailbox uses OAUTH - closes #5019 2025-10-05 21:52:54 -07:00
FreeScout
b590251393 body parameter for new-ticket - closes #5012 2025-10-05 11:22:16 -07:00
FreeScout
22ed3a775b Change out_username field maxlength to 255 - closes #5015 2025-10-05 10:52:39 -07:00
FreeScout
45aa0faf1d REDIS_CLIENT env parameter - closes #5009 2025-10-05 07:39:13 -07:00
FreeScout
701acfead9 Add mailbox_id-state-status index to conversations table - closes #4994 2025-09-06 02:36:50 -07:00
FreeScout
3bf9bb9611 v1.8.192 2025-08-30 22:53:02 -07:00
FreeScout
bc9bc6dffe Fix wrong user viewing conversation - closes #3801 2025-08-28 01:25:22 -07:00
FreeScout
27cef8055d Do not allow to merge customer with himself - closes #4986 2025-08-26 02:51:32 -07:00
FreeScout
76467c1771 Update Czech translation 2025-08-22 23:40:21 -07:00
FreeScout
c50226f4b3 PHP 8.4 compatibility - closes #4979 2025-08-22 23:23:04 -07:00
FreeScout
f9e238bb2c PHP 8.4 Compatibility - closes #4979 2025-08-21 05:06:16 -07:00
FreeScout
ab7073f7fd v1.8.191 2025-08-17 21:41:01 -07:00
FreeScout
68964b5f9c PHP 8.4 compatibility 2025-08-14 04:10:42 -07:00
FreeScout
e53aece5b7 Fix selecting conversations using SHIFT - closes #4962 2025-08-10 05:35:30 -07:00
FreeScout
1e8483a886 Remove SQLite from installer - closes #4959 2025-08-08 11:46:47 -07:00
FreeScout
45da3085db Add web_notification.header hook #4961 from Lars-/master
Add web_notification.header hook
2025-08-08 18:43:20 +02:00
Lars Jansen
77e81c9598 feat: more flexibility for notifications 2025-08-08 16:11:07 +02:00
FreeScout
324d8d861e Fix IF error in update.sh - closes #4952 2025-08-02 23:05:34 -07:00
FreeScout
19622ffd60 v1.8.190 2025-08-01 03:20:33 -07:00
FreeScout
251187d463 Add Ukrainian language 2025-07-30 23:32:50 -07:00
FreeScout
762e50d68e Change Receiving Emails to Fetching Emails in the warning in Connection Settings - closes #4949 2025-07-30 23:12:48 -07:00
FreeScout
c0cb3fa898 Add follow_redirects parameter to Helper::getRemoteFileContents() function 2025-07-29 03:57:36 -07:00
FreeScout
5b6e261c6a Sanitize URL in Helper::getRemoteFileContents() function 2025-07-28 05:02:42 -07:00
FreeScout
dcee75c499 Sanitize URL in Helper::getRemoteFileContents() function 2025-07-28 04:53:12 -07:00
FreeScout
baa8512d8e v1.8.189 2025-07-25 23:04:20 -07:00
FreeScout
1e92ac8d7b Fix error in MailHelper::getHeader() - closes #4940 2025-07-24 01:05:15 -07:00
354 changed files with 31865 additions and 8737 deletions

View File

@@ -12,8 +12,9 @@ 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)
# Enter your proxy address here. It will be used to connect to all external URLs via CURL.
# You may need to do specify proxy for example if api.freescout.net is not available from your server
# (access to api.freescout.net is required to obtain official modules).
#APP_PROXY=
# Custom headers to add to all outgoing emails.
@@ -31,6 +32,11 @@ APP_URL=https://example.com
# 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
# Set this parameter when receiving "Domain or IP address is not allowed".
# https://github.com/freescout-help-desk/freescout/wiki/Installation-Guide#-domain-or-ip-address-is-not-allowed
# Example: "example.org,12.34.56.78"
#APP_REMOTE_HOST_WHITE_LIST=""
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -1,3 +1,5 @@
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
Pull requests should be sent to the `master` branch: https://github.com/freescout-helpdesk/freescout/wiki/Development-Guide#github-workflow
Also one commit is preferable to several commits - it's easier to read and analize single commit.
Now you can delete this text and type the description of your pull request...

View File

@@ -1,6 +1,9 @@
name: PHP Code Sniffer
on:
push:
branches:
- master
workflow_dispatch:
jobs:

View File

@@ -32,7 +32,7 @@ jobs:
strategy:
matrix:
php: ['7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v3

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v3

4
.gitignore vendored
View File

@@ -28,10 +28,12 @@ Thumbs.db
/Modules/**/.git
/public/modules
/public/docs
/public/.dh-diag
/storage/.ignore_locales
/storage/.installed
/tools
.well-known
/resources/lang/module.*
.phpunit.result.cache
/.phpunit.cache
/.phpunit.cache
.env.testing

3
DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,3 @@
# Documentation
Documentation: https://github.com/freescout-help-desk/freescout/wiki

View File

@@ -3,13 +3,10 @@
<div align="center">
<img src="https://raw.githubusercontent.com/freescout-help-desk/freescout/master/public/img/logo-300.png" width="180" height="180" />
<br/><br/>
[![PHP version](https://freescout-helpdesk.github.io/img/badges/PHP-7.1%2B-blue.svg)](https://github.com/freescout-help-desk/freescout#requirements)
</div>
**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.
**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 does not use 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!
@@ -21,7 +18,7 @@ If you want to support the project feel free to **star this repository**. It hel
* [Mobile Apps](#mobile-apps)
* [Requirements](#requirements)
* [Installation](#installation)
* [Cloud Hosted](#cloud-hosted)
* [Images & One-Click Installs](#images--one-click-installs)
* [Modules](#modules)
* [Tools & Integrations](#tools--integrations)
* [News & Updates](#news--updates)
@@ -34,34 +31,36 @@ If you want to support the project feel free to **star this repository**. It hel
## Features
* No limitations on the number of users, tickets, mailboxes, etc.
* 100% Mobile-friendly.
* Multilingual: English, Chinese, Croatian, Czech, Danish, Dutch, Finnish, French, German, Hungarian, Italian, Japanese, Kazakh, Korean, Norwegian, Persian, Polish, Portuguese, Romanian, Russian, Spanish, Slovak, Swedish, Turkish.
* Seamless email integration.
* Supports modern Microsoft Exchange authentication.
* Omnichannel — FreeScout centralizes in one place all customer conversations received by email, [WhatsApp](https://freescout.net/whatsapp/), Telegram, Facebook, Slack, Live Chat and more.
* [AI-powered](https://github.com/freescout-help-desk/AiIntegration) and super efficient.
* Unlimited support agents, tickets, mailboxes, etc.
* [Secure](https://freescout.net/security) by design.
* Mobile-friendly.
* Multilingual (translated into 33 languages).
* Seamless email integration (via SMTP, IMAP, POP3, etc).
* Modern OAuth authentication for Microsoft Office 365 and Google Workspace.
* [Progressive Web App](https://freescout.net/progressive-app/) for phones and desktops (installable from browser and sending Web Push notifications).
* Native [S3 storage](https://github.com/freescout-help-desk/S3Storage) support.
* Fully supports screen readers (for visually impaired).
* Built with strong focus on [security](https://freescout.net/security).
* Web installer & updater.
* Starred conversations.
* Forwarding conversations.
* Merging conversations.
* Moving conversations between mailboxes.
* Push notifications.
* Following conversations.
* Auto replies.
* Internal notes.
* Forwarding / Merging / Moving conversations.
* 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.
* Flexible notifications for user agents.
* Open tracking.
* Editing threads.
* Search.
* And more…
* Powerful search.
Need anything else? Suggest features [here](https://freescout.net/request-feature/).
A full list of features is available [here](https://freescout.net/#custom_html-3).
Need anything else? [Suggest features](https://freescout.net/request-feature/).
## Mobile Apps
@@ -69,14 +68,14 @@ Mobile apps support the same functionality and modules as the web version of you
<a href="https://freescout.net/android-app/" target="_blank" rel="nofollow"><img alt="Android App" src="https://freescout-helpdesk.github.io/img/apps/android.png" width="200px" /></a> <a href="https://freescout.net/ios-app/" target="_blank" rel="nofollow"><img alt="iOS App" src="https://freescout-helpdesk.github.io/img/apps/ios.png?v=1" width="200px" /></a>
[MacOS Menu Bar App](https://github.com/jonalaniz/scouter)
[Progressive Web App for phones and desktops](https://freescout.net/progressive-app/) | [MacOS Menu Bar App](https://github.com/jonalaniz/scouter)
## Requirements
FreeScout is a pure PHP/MySQL application, so it can be easily deployed even on a [shared hosting](https://github.com/freescout-help-desk/freescout/wiki/Choosing-a-Server).
* Nginx / Apache / IIS
* PHP 7.1 - 8.x
* PHP 7.4 - 8.x
* MySQL 5.0+ / MariaDB 5.0+ / PostgreSQL
There are no minimum system requirements (CPU / RAM) – FreeScout will run on any system.
@@ -85,17 +84,18 @@ There are no minimum system requirements (CPU / RAM) – FreeScout will run on a
[Installation Guide](https://github.com/freescout-help-desk/freescout/wiki/Installation-Guide)
Images & one-click installs:
## 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)
* [Docker Image](https://freescout.net/docker/)
* [Pikapods](https://freescout.net/pikapods/) — Open-source apps hosting (Fully managed)
* [Zenith](https://freescout.net/zenith/) — Open-source apps hosting (Fully managed)
* [Hostinger](https://freescout.net/hostinger/) — VPS with automatic backups included
* [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-help-desk/freescout/wiki/Installation-Guide#interactive-installation-bash-script-ubuntu) (bash script)
* [Ubuntu](https://github.com/freescout-help-desk/freescout/wiki/Installation-Guide#interactive-installation-bash-script-ubuntu) — Bash script
## Cloud Hosted
[Cloud Hosted FreeScout](https://freescout.net/cloud-hosted/)
[More one-click installs…](https://freescout.net/one-click-installs/)
## Modules
@@ -132,10 +132,9 @@ Conversation:
![Conversation](https://freescout-helpdesk.github.io/img/screenshots/conversation.png)
Drafting reply with AI:
Mailbox connection settings page:
![Mailbox connection settings page](https://freescout-helpdesk.github.io/img/screenshots/mailbox-connection.png)
![Mailbox connection settings page](https://freescout-helpdesk.github.io/img/screenshots/draft-reply-with-ai.png)
Notifications:

View File

@@ -1,5 +1,17 @@
# Security Policy
Read about **[FreeScout's Security](https://freescout.net/security/)**.
## Reporting a Vulnerability
Email at support@freescout.net
* Report a security vulnerability in the **Application Core**: https://github.com/freescout-help-desk/freescout/security/advisories
* Report a security vulnerability in a **Module**: https://freescout.net/contact-us/
## Guidelines
* One issue per Advisory — do not put several issues into one Advisory, as GitHub will not assign a CVE to such an Advisory.
* CVEs are requested after the fix for the vulnerability is released.
* If you found an issue related to the "Manage » Settings » Customer Visibility" option (`APP_LIMIT_USER_CUSTOMER_VISIBILITY=true`), make sure to read the [FAQ](https://github.com/freescout-help-desk/freescout/wiki/FAQ#is-it-possible-to-have-separate-contactscustomers-per-mailbox).
* Support agents are allowed to move conversations to any mailbox, even to ones they don't have access to.
* Drafts are viewable/discardable by any member of the mailbox where they are created.
* FreeScout loads external images in received emails, and there is a Module allowing to disable loading external images: https://freescout.net/module/block-external-images/.

View File

@@ -19,9 +19,17 @@ class Attachment extends Model
const DIRECTORY = 'attachment';
CONST DISK = 'private';
const DISK = 'local_app';
CONST MIME_TYPE_MAX_LENGTH = 127;
const MIME_TYPE_MAX_LENGTH = 127;
// This token type was used for backward compatibility for some time
// and has been deprecated.
//const TOKEN_TYPE_LEGACY = 1;
// For backward compatibility.
const TOKEN_TYPE_MD5 = 2;
// Current way.
const TOKEN_TYPE_SHA256 = 3;
// https://github.com/Webklex/laravel-imap/blob/master/src/IMAP/Attachment.php
public static $types = [
@@ -37,7 +45,7 @@ class Attachment extends Model
];
public static $type_extensions = [
self::TYPE_VIDEO => ['flv', 'mp4', 'm3u8', 'ts', '3gp', 'mov', 'avi', 'wmv']
self::TYPE_VIDEO => ['flv', 'mp4', 'm3u8', 'ts', '3gp', 'mov', 'avi', 'wmv'],
];
public $timestamps = false;
@@ -53,7 +61,7 @@ class Attachment extends Model
/**
* 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)
public static function create($file_name, $mime_type, $type, $content, $uploaded_file, $embedded = false, $thread_id = null, $user_id = null, $upload_mode = \Helper::UPLOAD_MODE_DEFAULT)
{
if (!$content && !$uploaded_file) {
return false;
@@ -70,7 +78,7 @@ class Attachment extends Model
$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);
$file_name = \Helper::sanitizeUploadedFileName($file_name, $uploaded_file, $content, $mime_type, $upload_mode);
// Replace some symbols in file name.
// Gmail can not load image if it contains spaces.
@@ -121,12 +129,13 @@ class Attachment extends Model
$attachment->mime_type = $mime_type;
$attachment->type = $type;
$attachment->embedded = $embedded;
$attachment->token_type = self::TOKEN_TYPE_SHA256;
$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->size = self::getDisk()->size($file_info['file_path']);
$attachment->save();
return $attachment;
@@ -134,6 +143,7 @@ class Attachment extends Model
/**
* Save file to the disk and return file_dir.
* $content may be a resource pointing to the file in remote storage.
*/
public static function saveFileToDisk($attachment, $file_name, $content, $uploaded_file)
{
@@ -146,21 +156,24 @@ class Attachment extends Model
do {
$i++;
$file_path = self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir.$i.DIRECTORY_SEPARATOR.$file_name;
} while (Storage::disk(self::DISK)->exists($file_path));
} while (self::getDisk()->exists($file_path));
$file_dir .= $i.DIRECTORY_SEPARATOR;
try {
if ($uploaded_file) {
$uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name, ['disk' => self::DISK]);
$uploaded_file->storeAs(self::DIRECTORY.DIRECTORY_SEPARATOR.$file_dir, $file_name, self::getDiskName());
} else {
Storage::disk(self::DISK)->put($file_path, $content);
self::getDisk()->put($file_path, $content);
}
} catch (\Exception $e) {
\Helper::logException($e, '[Attachment::saveFileToDisk()]');
}
\Helper::sanitizeUploadedFileData($file_path, \Helper::getPrivateStorage(), $content);
// $content may be a stream resource rather than a string; the SVG sanitizer needs
// actual string content, so let it re-read the just-written file in that case instead
// of treating the resource as file content.
\Helper::sanitizeUploadedFileData($file_path, is_resource($content) ? null : $content);
return [
'file_dir' => $file_dir,
@@ -257,7 +270,7 @@ class Attachment extends Model
// URL must contain only forward slashes.
$file_path = str_replace(DIRECTORY_SEPARATOR, '/', $file_path);
$file_url = Storage::url($file_path);
$file_url = self::getDisk()->url($file_path);
// Fix percents.
// https://github.com/freescout-helpdesk/freescout/issues/3530
@@ -271,18 +284,23 @@ class Attachment extends Model
*/
public function getToken()
{
// \Hash::make() may contain . and / symbols which may cause problems.
return md5(config('app.key').$this->id.$this->size);
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-89ww-mfww-5vr6
/*if ($this->token_type == self::TOKEN_TYPE_MD5) {
// Backward compatibility.
// \Hash::make() may contain . and / symbols which may cause problems.
return md5(config('app.key').$this->id.$this->size);
} else {*/
return hash_hmac('sha256', $this->id.$this->size.$this->file_name, config('app.key'));
}
/**
* Outputs the current Attachment as download
*/
public function download($view = false)
public function download($view = false, $headers = [])
{
$headers = [];
// #533
//return $this->getDisk()->download($this->getStorageFilePath(), \Str::ascii($this->file_name));
//return self::getDisk()->download($this->getStorageFilePath(), \Str::ascii($this->file_name));
if ($view) {
$headers['Content-Disposition'] = '';
}
@@ -292,11 +310,10 @@ class Attachment extends Model
$file_name = $file_name.'.eml';
}
return $this->getDisk()->download($this->getStorageFilePath(), $file_name, $headers);
}
// Cache attachments in browser - 1 month.
$headers['Cache-Control'] = 'max-age=2592000';
private function getDisk() {
return Storage::disk(self::DISK);
return self::getDisk()->download($this->getStorageFilePath(), $file_name, $headers);
}
/**
@@ -323,7 +340,7 @@ class Attachment extends Model
public function getLocalFilePath($full = true)
{
if ($full) {
return $this->getDisk()->path(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
return self::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;
}
@@ -334,7 +351,7 @@ class Attachment extends Model
*/
public function fileExists()
{
return $this->getDisk()->exists(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
return self::getDisk()->exists(self::DIRECTORY.DIRECTORY_SEPARATOR.$this->file_dir.$this->file_name);
}
public static function formatBytes($size, $precision = 0)
@@ -385,7 +402,7 @@ class Attachment extends Model
{
// Delete from disk
foreach ($attachments as $attachment) {
$attachment->getDisk()->delete($attachment->getStorageFilePath());
self::getDisk()->delete($attachment->getStorageFilePath());
}
// Delete from DB
@@ -426,6 +443,10 @@ class Attachment extends Model
*/
public function duplicate($thread_id = null)
{
if (!$this->fileExists()) {
return null;
}
$new_attachment = $this->replicate();
$new_attachment->thread_id = $thread_id;
@@ -433,12 +454,20 @@ class Attachment extends Model
$new_attachment->save();
try {
$attachment_file = new \Illuminate\Http\UploadedFile(
$this->getLocalFilePath(), $this->file_name,
null, null, true
);
$content_stream = '';
$attachment_file = null;
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, '', $attachment_file);
if (\Helper::isLocalStorage()) {
$attachment_file = new \Illuminate\Http\UploadedFile(
$this->getLocalFilePath(), $this->file_name,
null, null, true
);
} else {
// File in remote storage.
$content_stream = $this->getFileStream();
}
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, $content_stream, $attachment_file);
if (!empty($file_info['file_dir'])) {
$new_attachment->file_dir = $file_info['file_dir'];
@@ -453,6 +482,41 @@ class Attachment extends Model
public function getFileContents()
{
return $this->getDisk()->get($this->getStorageFilePath());
try {
return self::getDisk()->get($this->getStorageFilePath());
} catch (\Exception $e) {
// File not found at path:...
\Helper::logException($e, '[Attachment::getFileContents()]');
return '';
}
}
/**
* Same as getFileContents(), but as a stream instead of loading the whole
* file into memory - use this when the bytes are only being copied
* elsewhere (e.g. duplicating/forwarding an attachment).
*/
public function getFileStream()
{
try {
return self::getDisk()->readStream($this->getStorageFilePath());
} catch (\Exception $e) {
\Helper::logException($e, '[Attachment::getFileStream()]');
return null;
}
}
public static function getDiskName()
{
if (\Helper::isLocalStorage()) {
return self::DISK;
} else {
return config('filesystems.default');
}
}
public static function getDisk()
{
return Storage::disk(self::getDiskName());
}
}

View File

@@ -56,6 +56,10 @@ class PolycastBroadcaster extends Broadcaster
*/
public function auth($request)
{
if (!$request->user()) {
throw new AccessDeniedHttpException();
}
// For connect request
if (empty($request->channels)) {
return true;
@@ -63,12 +67,9 @@ class PolycastBroadcaster extends Broadcaster
// 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();
}
// Copied from Illuminate\Broadcasting\Broadcasters\PusherBroadcaster
//if (Str::startsWith($channel_name, ['private-', 'presence-']) &&
if (Str::startsWith($channel_name, ['private-', 'presence-'])) {
$channelName = Str::startsWith($channel_name, 'private-')

View File

@@ -40,5 +40,7 @@ class AfterAppUpdate extends Command
$this->call('freescout:clear-cache');
$this->call('migrate', ['--force' => true]);
$this->call('queue:restart');
\Eventy::action('command.after_app_update');
}
}

View File

@@ -20,7 +20,7 @@ class CleanTmp extends Command
*
* @var string
*/
protected $description = 'Remove from system temp folder FreeScout files older than 1 week to avoid "No space left on device"';
protected $description = 'Remove from system temp folder old FreeScout tempt files to avoid "No space left on device"';
/**
* Create a new command instance.
@@ -39,8 +39,15 @@ class CleanTmp extends Command
*/
public function handle()
{
// General cleaning.
// Remove 1 week old FreeScout files.
\Helper::shellExec('find '.\Helper::getTempDir().' -mtime +7 -type f -name '.\Helper::getTempFilePrefix().'* -exec rm -r -f {} \;');
// Remove temporary 1 day old SwiftMailer files.
// Example: /tmp/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6/body
// https://github.com/freescout-help-desk/freescout/issues/5558
\Helper::shellExec('find '.\Helper::getTempDir().' -mtime +1 -type d -regextype posix-extended -regex ".*/[a-f0-9]{32}" -exec rm -r -f {} \;');
$this->comment("Done");
}
}

View File

@@ -67,10 +67,16 @@ class ClearCache extends Command
// // Do nothing.
// }
// Clear JS and CSS builds (generated in prod environment).
\Helper::shellExec('find '.public_path('js/builds').' -type f -name "*.js" ! -name "vars.js" -exec rm -r -f {} \;');
\Helper::shellExec('find '.public_path('css/builds').' -type f -name "*.css" -exec rm -r -f {} \;');
$this->info("Cleared: JS and CSS builds");
// 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();

View File

@@ -48,8 +48,8 @@ class CreateUser extends Command
$user = new $class();
$user->role = $this->option('role');
if ($user->role ) {
if (!in_array($user->role , User::$roles)) {
if ($user->role) {
if (!in_array($user->role, User::$roles)) {
$this->error('Invalid role');
return false;
}

View File

@@ -10,6 +10,7 @@ use App\Events\ConversationCustomerChanged;
use App\Events\CustomerCreatedConversation;
use App\Events\CustomerReplied;
use App\Events\UserReplied;
use App\Jobs\SendEmailReplyError;
use App\Mailbox;
use App\Misc\Mail;
use App\Option;
@@ -114,7 +115,7 @@ class FetchEmails extends Command
);
foreach ($this->mailboxes as $mailbox) {
if (!$mailbox->isInActive()) {
if (!$mailbox->isInActive() || !$mailbox->isActive()) {
continue;
}
if ($mailboxIds !== [] && !in_array($mailbox->id, $mailboxIds, true)) {
@@ -140,7 +141,7 @@ class FetchEmails extends Command
// (when there are many mailboxes for example),
// we increase connection sleep time and retry after sleep.
// https://github.com/freescout-help-desk/freescout/issues/4227
if (trim($e->getMessage()) == 'connection setup failed') {
if (\Str::startsWith($e->getMessage(), 'connection setup failed')) {
$sleep += 500000;
usleep(self::MAX_SLEEP);
@@ -225,11 +226,22 @@ class FetchEmails extends Command
} catch (\Exception $e) {
$error = $e->getMessage();
// POP3 uses LegacyProtocol.php
// For POP3. Before it used LegacyProtocol.php.
// Keeping it in case PopProtocol.php also throws such error.
// https://github.com/freescout-helpdesk/freescout/issues/4060
if ($error && \Str::startsWith($error, 'Mailbox is empty')) {
$this->line('['.date('Y-m-d H:i:s').'] Fetched: 0');
return;
}
// Retry one transient TLS handshake failure.
// https://github.com/freescout-help-desk/freescout/pull/5626
if ($error
&& \Str::startsWith($error, 'connection failed - stream_socket_client(): SSL operation failed')
&& stripos($error, 'SSL routines::wrong version number') !== false
) {
usleep(self::MAX_SLEEP);
$client->connect();
} else {
throw $e;
}
@@ -273,8 +285,43 @@ class FetchEmails extends Command
// Requesting emails by bunches allows to fetch large amounts of emails
// without problems with memory.
$page = 0;
do {
//
// Compute how many bunches to fetch from a plain IMAP SEARCH count up
// front, instead of driving the loop off the per-bunch materialized count.
// That avoids two bugs:
// (1) Silent loss of the NEWEST emails (#4624): a single message that fails
// to materialize in a batch fetch makes a bunch come back short; the old
// `while (count($messages) == $page_size)` then stopped before fetching
// the later bunches. With fetch_order='asc' those later bunches hold the
// newest emails, so they were dropped silently.
// (2) Looping "until an empty page" instead would fetch one page past the
// end, and the IMAP layer throws "Array to string conversion" when asked
// to fetch an empty UID list.
// Webklex Query::limit()/forPage() are 1-indexed, so pages start at 1.
//
// NOTE: this assumes a stable search set across pages (fetch_unseen=0). With
// fetch_unseen=1, processMessage() marks messages \Seen mid-loop, shrinking
// the unseen set between pages and shifting forPage() — a separate,
// pre-existing drift (#4047), not addressed here.
try {
$count_query = $folder->query()->since(now()->subDays($this->option('days')))->leaveUnread();
if ($unseen) {
$count_query->unseen();
}
if ($no_charset) {
$count_query->setCharset(null);
}
$total_messages = $count_query->count();
} catch (\Exception $e) {
// If the SEARCH count fails (e.g. charset issues on some servers), fall
// back to a single bunch so we never silently fetch nothing. The
// per-bunch charset retry below still recovers the rest on such servers.
$total_messages = $page_size;
$this->logError('Folder: '.$folder->name.'; Count error: '.$e->getMessage());
}
$total_pages = ($page_size > 0) ? (int) ceil($total_messages / $page_size) : 1;
for ($page = 1; $page <= $total_pages; $page++) {
// Get messages.
$last_error = '';
$messages = collect([]);
@@ -291,9 +338,7 @@ class FetchEmails extends Command
$messages = $messages_query->get();
if (method_exists($client, 'getLastError')) {
$last_error = $client->getLastError();
}
$last_error = $client->getLastError();
} catch (\Exception $e) {
$last_error = $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')'.')';
}
@@ -309,11 +354,7 @@ class FetchEmails extends Command
$messages = $messages_query->get();
$no_charset = true;
if (count($client->getErrors()) > $errors_count) {
$last_error = $client->getLastError();
} else {
$last_error = null;
}
$last_error = $client->getLastError();
}
if ($last_error && !\Str::startsWith($last_error, 'Mailbox is empty')) {
@@ -339,8 +380,7 @@ class FetchEmails extends Command
$dest_mailbox = \Eventy::filter('fetch_emails.mailbox_to_save_message', $mailbox, $folder);
$this->processMessage($message, $message_id, $dest_mailbox, $this->mailboxes);
}
$page++;
} while (count($messages) == $page_size);
}
}
$client->disconnect();
@@ -372,6 +412,7 @@ class FetchEmails extends Command
}*/
if ($from) {
// Also sanitizes email address.
$from = $this->formatEmailList($from);
}
@@ -413,7 +454,7 @@ class FetchEmails extends Command
$duplicate_message_id = Thread::where('message_id', $message_id)->first();
}
// Mailbox has been mentioned in Bcc.
// Mailbox has been mentioned in Bcc or email originates from within FreeScout.
if (!$extra && $duplicate_message_id) {
$recipients = array_merge(
@@ -421,7 +462,11 @@ class FetchEmails extends Command
$this->formatEmailList($message->getCc())
);
if (!in_array(Email::sanitizeEmail($mailbox->email), $recipients)
// $is_outbound_from_fs is needed to fetch messages sent from one FreeScout mailbox to another.
// https://github.com/freescout-help-desk/freescout/issues/5368#issuecomment-4309140388
$is_outbound_from_fs = \MailHelper::isFsMessageId($duplicate_message_id->message_id);
if ((!in_array(Email::sanitizeEmail($mailbox->email), $recipients) || $is_outbound_from_fs)
// Make sure that previous email has been imported into other mailbox.
&& $duplicate_message_id->conversation
&& $duplicate_message_id->conversation->mailbox_id != $mailbox->id
@@ -431,11 +476,26 @@ class FetchEmails extends Command
}
}
// Gnerate artificial Message-ID if importing same email into several mailboxes.
if ($extra) {
// Is this a new email having the same Message-ID.
// https://github.com/freescout-help-desk/freescout/issues/5591
$new_email_with_same_message_id = false;
if (!$extra && $duplicate_message_id) {
// Compare headers
if ($duplicate_message_id->headers != $this->headerToStr($message->getHeader())) {
$new_email_with_same_message_id = true;
}
}
// Gnerate artificial Message-ID if importing same email into several mailboxes
// or this is a new email having the same Message-ID.
if ($extra || $new_email_with_same_message_id) {
// 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);
// https://github.com/freescout-help-desk/freescout/issues/5434
$duplicate_message_id = Thread::where('message_id', $message_id)->first();
}
// Check if message already fetched.
@@ -482,11 +542,11 @@ class FetchEmails extends Command
// 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,
];
// $reply_prefixes = [
// \MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION,
// \MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER,
// \MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY,
// ];
// Try to get previous message ID from marker in body.
$html_body = $message->getHTMLBody(false);
@@ -551,8 +611,12 @@ class FetchEmails extends Command
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') {
// message/rfc822: 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.
//
// text/rfc822-headers: https://github.com/freescout-help-desk/freescout/issues/5583
if (in_array($attachment_msg->content_type, ['message/rfc822', 'text/rfc822-headers'])) {
$bounced_message_id = \MailHelper::getHeader($attachment_msg->getContent(), 'message_id');
if ($bounced_message_id) {
break;
@@ -561,14 +625,58 @@ class FetchEmails extends Command
}
}
# Try to get the thread traversing the possible prev_message_ids
// 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 = \Helper::iconvMimeDecode($subject);
}
// Add to prev messaged IDs auto-generated Message-IDs.
foreach ($prev_message_ids as $prev_message_id) {
$new_prev_message_id = \MailHelper::generateMessageId($prev_message_id, $mailbox->id.$prev_message_id);
if (!in_array($new_prev_message_id, $prev_message_ids)) {
$prev_message_ids[] = $new_prev_message_id;
}
}
$prev_message_ids = array_values(array_unique($prev_message_ids));
# Try to get the thread traversing the possible prev_message_ids
foreach ($prev_message_ids as $i => $prev_message_id) {
$is_reply = false;
if (!$prev_message_id) {
continue;
}
// 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);
preg_match('/^'.$this->formatMessageIdPrefix(\MailHelper::MESSAGE_ID_PREFIX_NOTIFICATION)."\-(\d+)\-(\d+)\-([a-z0-9]+)/", $prev_message_id, $m);
if (!$is_bounce && !empty($m[1]) && !empty($m[2])) {
// Reply from User to the notification
// Reply from User to the notification.
$prev_thread_id = '';
// Check hash.
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-6r38-6mcf-2ww3
if (!empty($m[1]) && !empty($m[3])) {
$message_id_hash = $m[3];
if (strlen($message_id_hash) == 16) {
if ($message_id_hash == \MailHelper::getMessageIdHash($m[1])) {
$prev_thread_id = $m[1];
}
} else {
// No backward compatibility for security reasons.
//$prev_thread_id = $m[1];
}
}
if (!$prev_thread_id) {
$this->logError('Invalid hash in the Message-ID: '.$prev_message_id);
$this->setSeen($message, $mailbox);
return;
}
$prev_thread = Thread::find($m[1]);
$user_id = $m[2];
$user = User::find($user_id);
$message_from_customer = false;
@@ -592,14 +700,29 @@ class FetchEmails extends Command
$this->line('['.date('Y-m-d H:i:s').'] Message from: Customer');
if (!$is_bounce) {
if ($prev_message_id) {
$prev_thread_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.
// Customer replied to the email from user
preg_match('/^'.$this->formatMessageIdPrefix(\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 (deprecated).
//$prev_thread_id = $m[1];
}
}
// Customer replied to the auto reply
if (!$prev_thread_id) {
preg_match('/^'.$this->formatMessageIdPrefix(\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) {
@@ -607,79 +730,157 @@ class FetchEmails extends Command
$prev_thread_id = $m[1];
}
} else {
// Backward compatibility.
$prev_thread_id = $m[1];
// Backward compatibility (deprecated).
//$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();
}
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;
}
// Reply from user to his reply to the notification
if (!$prev_thread
&& ($prev_thread = Thread::where('message_id', $prev_message_id)->first())
&& $prev_thread->created_by_user_id
&& $prev_thread->created_by_user->hasEmail($from)
) {
$user_id = $user->id;
$message_from_customer = false;
$is_reply = true;
}
}
}
# If a thread is found, we keep it and break
# If a thread is found, we keep it and check
if (!empty($prev_thread)) {
$is_reply = true;
break;
}
}
// Make sure that prev_thread belongs to the current mailbox.
// Problems may arise when forwarding conversation for example.
//
// For replies to email notifications it's allowed to have prev_thread in
// another mailbox as conversation can be moved.
// https://github.com/freescout-helpdesk/freescout/issues/3455
if ($prev_thread && $message_from_customer) {
if ($prev_thread->conversation->mailbox_id != $mailbox->id) {
// https://github.com/freescout-helpdesk/freescout/issues/2807
// Behaviour of email sent to multiple mailboxes:
// If a user from either mailbox replies, then a new conversation is created
// in the other mailbox with another new conversation ID.
// Reply from customer.
//
// Make sure that prev_thread belongs to the current mailbox.
// Problems may arise when forwarding conversation for example.
if ($prev_thread && $message_from_customer) {
if ($prev_thread->conversation->mailbox_id != $mailbox->id) {
// Try to get thread by artificial message ID.
if ($in_reply_to) {
// If customer sends an email to multiple mailboxes:
// - A conversation is created for each mailbox.
// - An agent responds from each mailbox and includes other mailboxes in CC.
// - The customer replies to each email.
// https://github.com/freescout-help-desk/freescout/issues/5308
// Is this a reply to the message with artificial Message-ID.
$message_ids = [\MailHelper::generateMessageId($in_reply_to, $mailbox->id.$in_reply_to), $in_reply_to];
$prev_thread_tmp = Thread::whereIn('message_id', $message_ids)->whereHas('conversation', function ($q) use ($mailbox) {
$q->where('mailbox_id', $mailbox->id);
})->first();
if ($prev_thread_tmp) {
$prev_thread = $prev_thread_tmp;
} elseif (\MailHelper::isGeneratedMessageId($prev_thread->message_id)) {
// Try to extract the original Message-ID from the mismatched thread's raw headers
// https://github.com/freescout-help-desk/freescout/issues/5308#issuecomment-4218652527
$original_id = trim(\MailHelper::getHeader($prev_thread->headers, 'Message-ID'));
$correct_thread = null;
// Search the current mailbox for BOTH the original ID and this mailbox's generated hash of the original ID
if ($original_id) {
$search_ids = [
$original_id,
\MailHelper::generateMessageId($original_id, $mailbox->id.$original_id),
];
$correct_thread = Thread::whereIn('message_id', $search_ids)
->whereHas('conversation', function ($q) use ($mailbox) {
$q->where('mailbox_id', $mailbox->id);
})->first();
}
if ($correct_thread) {
$prev_thread = $correct_thread;
}
if (!$correct_thread) {
$prev_thread = null;
}
} else {
// Prev Thread SHOULD BE PRESERVED in the following case:
// - Existing conversation is moved from Mailbox A to Mailbox B.
// - Customer replies to some earlier received reply from an agent.
// - The email is fetched into Mailbox A (as To in in the email points to Mailbox A).
// - The reply creates a new conversation in Mailbox A instead of
// being linked to the existing conversation in Mailbox B.
// https://github.com/freescout-help-desk/freescout/issues/5590
//
// Search for LINE_ITEM threads and check if this conversations
// was in Mailbox A at some point (action_data contains prev mailbox IDs).
$prev_mailbox_ids = Thread::where('conversation_id', $prev_thread->conversation_id)
//->where('state', Thread::STATE_PUBLISHED)
->where('action_type', Thread::ACTION_TYPE_MOVED_FROM_MAILBOX)
->pluck('action_data')
->toArray();
if (!in_array($mailbox->id, $prev_mailbox_ids)) {
// Prev Thread has to be reset in the following case:
// - Customer sends an email to Mailbox A and B.
// - Support agent replies in Mailbox A and CCs Mailbox B.
// - Customer replies to this email keeping Mailbox B in copy.
// - Mailbox B fetches customer reply and sees that In-Reply-To is linked to a thread from Mailbox A
// (as Message-ID of agent email contains -thread_id- in it and thread_id belongs to Mailbox A).
// - FreeScout links this agent's reply to the conversation in Mailbox A instead of creating a new
// conversation in Mailbox B.
// https://github.com/freescout-help-desk/freescout/issues/5350
$prev_thread = null;
}
}
if (!$prev_thread) {
$prev_thread = null;
$is_reply = false;
}
} else {
$prev_thread = null;
$is_reply = false;
}
}
}
// Agent's reply to the email notification.
//
// 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) {
// If agent forwards email notification to another mailbox, create
// a new conversation instead of adding agent's reply to the existing conversation.
// https://github.com/freescout-help-desk/freescout/pull/5517
// https://github.com/freescout-help-desk/freescout/issues/4515
//
// For user 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 (// Subject looks like FWD:
preg_match("/^[[:alpha:]]{1,3}\s*:(.*)/i", trim($subject ?? ''))
// And reply sent to another mailbox.
&& $prev_thread->conversation->mailbox_id != $mailbox->id
) {
$prev_thread = null;
$is_reply = false;
$message_from_customer = true;
$this->line('['.date('Y-m-d H:i:s').'] Forwarded email notification detected. Creating a new conversation.');
break;
}
}
if (!$prev_thread && $i < (count($prev_message_ids)-1)) {
// Try another prev_message_id.
continue;
} else {
$prev_thread = null;
$is_reply = false;
break;
}
}
}
@@ -699,6 +900,7 @@ class FetchEmails extends Command
$body = $message->getTextBody() ?? '';
$body = htmlspecialchars($body);
}
$body = \Helper::utf8Encode($body);
// We have to fetch absolutely all emails, even with empty body.
// if (!$body) {
@@ -707,14 +909,6 @@ class FetchEmails extends Command
// 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());
@@ -748,6 +942,11 @@ class FetchEmails extends Command
if ($original_sender) {
// Check if sender is the existing user.
$sender_is_user = User::nonDeleted()->where('email', $from)->exists();
// Check Alternate emails.
// https://github.com/freescout-help-desk/freescout/issues/5047
if (!$sender_is_user) {
$sender_is_user = User::findByAlternateEmail($from);
}
if ($sender_is_user) {
// Substitute sender.
@@ -811,6 +1010,7 @@ class FetchEmails extends Command
$new_thread = null;
if ($message_from_customer) {
// SAVE CUSTOMER MESSAGE.
// We should import the message into other mailboxes even if previous thread is set.
// https://github.com/freescout-helpdesk/freescout/issues/3473
@@ -857,6 +1057,8 @@ class FetchEmails extends Command
return;
}
} else {
// SAVE SUPPORT AGENT MESSAGE.
// 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)) {
@@ -864,17 +1066,25 @@ class FetchEmails extends Command
$this->setSeen($message, $mailbox);
// Send "Unable to process your update email" to user
\App\Jobs\SendEmailReplyError::dispatch($from, $user, $mailbox)->onQueue('emails');
SendEmailReplyError::dispatch($from, $user, $mailbox)->onQueue('emails');
return;
}
// Save user thread only if there prev_thread is set.
// Save user thread only if there is prev_thread 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);
SendEmailReplyError::dispatch($from, $user, $mailbox, __("The conversation you replied to could not be found."))->onQueue('emails');
return;
}
// Check user access to the mailbox and conversation.
if (!$user->can('view', $prev_thread->conversation)) {
$this->logError("Support agent (ID: ".$user->id.") has no accesss to the conversation #".$prev_thread->conversation->number." anymore and can not reply to the conversation.");
$this->setSeen($message, $mailbox);
SendEmailReplyError::dispatch($from, $user, $mailbox, __("You no longer have access to the conversation you replied to. Please contact your administrator."))->onQueue('emails');
return;
}
@@ -904,6 +1114,12 @@ class FetchEmails extends Command
}
}
// Get prefix as regex (for backward compatibility).
public function formatMessageIdPrefix($prefix)
{
return str_replace('FS_', '(?:FS_)?', $prefix);
}
// Try to get "From:" from body.
public function getOriginalSenderFromFwd($body)
{
@@ -912,12 +1128,19 @@ class FetchEmails extends Command
// Cut out the command, otherwise it will be recognized as an email.
$body = preg_replace("/".self::FWD_AS_CUSTOMER_COMMAND."([\s<]+)/isu", '$1', $body);
// https://github.com/freescout-help-desk/freescout/issues/5480
// Strip mailto: prefix to avoid false positive match
$body = preg_replace("/mailto:/i", "", $body);
// Decode HTML entities so &lt;email&gt; becomes <email>
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, "UTF-8");
// Strip \@ prefix used in CSS CJK font names (e.g. \@DengXian) to avoid false positive match
$body = preg_replace("/\\\@/", "", $body);
// Looks like email texts may appear in attributes:
// https://github.com/freescout-helpdesk/freescout/issues/276
// - :test@example.org
// - <test@example.org>
// - &lt;test@example.org&gt;
preg_match("/[\"'<:;]([^\"'<:;!@\s]+@[^\"'>:&@\s]+)[\"'>:&]/", $body, $b);
$email = $b[1] ?? '';
@@ -933,8 +1156,8 @@ class FetchEmails extends Command
$bounced_thread = null;
if ($bounced_message_id) {
$prefixes = [
\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER,
\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY,
$this->formatMessageIdPrefix(\MailHelper::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER),
$this->formatMessageIdPrefix(\MailHelper::MESSAGE_ID_PREFIX_AUTO_REPLY),
];
preg_match('/^('.implode('|', $prefixes).')\-(\d+)\-/', $bounced_message_id, $matches);
if (!empty($matches[2])) {
@@ -1008,8 +1231,8 @@ class FetchEmails extends Command
$conversation = null;
$prev_customer_id = null;
if ($use_mail_date_on_fetching) {
$now = $date;
}else{
$now = $date->format('Y-m-d H:i:s');
} else {
$now = date('Y-m-d H:i:s');
}
$conv_cc = $cc;
@@ -1067,11 +1290,14 @@ class FetchEmails extends Command
$conversation->setBcc($bcc);
}
$conversation->customer_email = $from;
// Reply from customer makes conversation active
if ($conversation->status != Conversation::STATUS_ACTIVE) {
// Reply from customer makes conversation active.
// If conversation is marked as Spam the status does not change.
// https://github.com/freescout-help-desk/freescout/issues/5005
if (!$conversation->isActive() && !$conversation->isSpam()) {
$conversation->status = \Eventy::filter('conversation.status_changing', Conversation::STATUS_ACTIVE, $conversation);
}
$conversation->last_reply_at = $now;
$conversation->setLastReplyAt($now, Conversation::PERSON_CUSTOMER);
$conversation->last_reply_from = Conversation::PERSON_CUSTOMER;
// Reply from customer to deleted conversation should undelete it.
if ($conversation->state == Conversation::STATE_DELETED) {
@@ -1161,17 +1387,45 @@ class FetchEmails extends Command
\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));
// }
// Simply returning original customer is disabled for better security:
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-j49f-9g94-3wh4
// Return original customer back.
// Change customer when reply to the conversation from a completely new email is received.
// Check From, To, Cc and Bcc in all previous threads.
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();
$change_customer = true;
$prev_threads = Thread::select(['id', 'from', 'to', 'cc', 'bcc'])
->where('conversation_id', $conversation->id)
->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE])
->get();
foreach ($prev_threads as $prev_thread) {
if ($prev_thread->id == $thread->id) {
continue;
}
// Sender has been already mentioned in the conversation -
// no need to change the customer.
if ($prev_thread->from == $from
|| in_array($from, $prev_thread->getTo())
|| in_array($from, $prev_thread->getCc())
|| in_array($from, $prev_thread->getBcc())
) {
$change_customer = false;
break;
}
}
if ($change_customer) {
event(new ConversationCustomerChanged($conversation, $prev_customer_id, $prev_customer_email, null, $customer));
} else {
// Return original customer back.
$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;
@@ -1188,7 +1442,7 @@ class FetchEmails extends Command
$conversation = null;
if ($use_mail_date_on_fetching) {
$now = $date;
}else{
} else {
$now = date('Y-m-d H:i:s');
}
$user_id = $user->id;
@@ -1225,8 +1479,9 @@ class FetchEmails extends Command
// 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) {
$new_status = ($mailbox->ticket_status == Mailbox::TICKET_STATUS_KEEP_CURRENT ? $conversation->status : $mailbox->ticket_status);
if ($new_status != $prev_status) {
$conversation->setStatus($new_status, $user, $update_folder = false);
\Eventy::action('conversation.status_changed', $conversation, $user, true, $prev_status);
}
$conversation->last_reply_at = $now;
@@ -1365,11 +1620,15 @@ class FetchEmails extends Command
$result = '';
// Can fix broken HTML, remove sensitive parts and etc.
$body = \Eventy::filter('fetch_emails.separate_reply.preprocess_body', $body);
$body = \Eventy::filter('fetch_emails.separate_reply.preprocess_body', $body ?? '');
if ($is_html) {
// Remove Outlook "downlevel-revealed" conditional comments (<![if !vml]>...<![endif]>),
// preserving the data inside. Otherwise DOMDocument turns them into escaped text.
$body = preg_replace('/<!\[(?:if\s[^\]]*|endif)\]>/i', '', $body) ?: $body;
// Extract body content from HTML
// Proton has it's own unique way of placing replies:
// https://github.com/freescout-help-desk/freescout/issues/4537#issuecomment-2629836738
if ($is_reply
@@ -1384,6 +1643,21 @@ class FetchEmails extends Command
$htmls = [];
preg_match_all("/<html[^>]*>(.*?)<\/html>/is", $body, $htmls);
// Some clients (e.g. Yahoo Android) top-post the customer's new reply as plain
// HTML fragments before the first <html> block, which wraps only the quoted
// prior message. Here we prepend that pre-<html> content so it enters $result and the
// reply-separator logic can extract it correctly.
// https://github.com/freescout-help-desk/freescout/issues/5409
if (!empty($htmls[0])) {
$first_html_pos = mb_strpos($body, '<html');
if ($first_html_pos > 0) {
$pre_html = mb_substr($body, 0, $first_html_pos);
if (trim(strip_tags($pre_html))) {
$result .= $pre_html;
}
}
}
if (empty($htmls[0])) {
$htmls[0] = [$body];
}
@@ -1393,7 +1667,8 @@ class FetchEmails extends Command
libxml_use_internal_errors(true);
//$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
//$dom->loadHTML(\Helper::mbConvertEncodingHtmlEntities($html));
$dom->loadHTML(\Symfony\Polyfill\Mbstring\Mbstring::mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') ?: $html);
// LIBXML_PARSEHUGE - https://github.com/freescout-help-desk/freescout/issues/5304
$dom->loadHTML(\Symfony\Polyfill\Mbstring\Mbstring::mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') ?: $html, LIBXML_PARSEHUGE);
libxml_use_internal_errors(false);
$bodies = $dom->getElementsByTagName('body');
if ($bodies->length == 1) {
@@ -1430,6 +1705,7 @@ class FetchEmails extends Command
}
// Try to separate customer reply using hashed reply separator.
// No other reply separators are used in this case.
// In this case some extra text may appear below customer's reply:
// On Thu, Jan 4, 2024 at 8:41 AM John Doe | Demo <test@example.org> wrote:
if (config('app.alternative_reply_separation')) {
@@ -1449,6 +1725,14 @@ class FetchEmails extends Command
$parts = explode($reply_separator, $result);
}
if (count($parts) > 1) {
// When replying to an email notification Outlook places its reply header
// (<hr> + div#divRplyFwdMsg containing From/Sent/To/Subject) above the
// quoted notification, i.e. above the separator, so it has to be removed
// from the extracted reply separately.
// https://github.com/freescout-help-desk/freescout/issues/5545
if ($user_reply_to_notification) {
$parts[0] = preg_replace('/<hr[^>]*>\s*<div[^>]+id="(?:x_)?divRplyFwdMsg".*$/is', '', $parts[0]) ?: $parts[0];
}
// Check if part contains any real text.
$text = \Helper::htmlToText($parts[0]);
$text = trim($text);
@@ -1585,7 +1869,7 @@ class FetchEmails extends Command
if (!is_string($header)) {
$header = $header->raw;
}
return $header;
return \Helper::utf8Encode($header);
}
/**
@@ -1598,12 +1882,12 @@ class FetchEmails extends Command
public function sortMessage($messages)
{
$messages = $messages->sortBy(function ($message, $key) {
$date = $message->getDate();
$date = $this->attrToDate($message->getDate());
if ($date) {
if (isset($message->getDate()->timestamp)) {
return $message->getDate()->timestamp;
if (isset($date->timestamp)) {
return $date->timestamp;
} else {
return (string)$message->getDate();
return (string)$date;
}
} else {
return 0;
@@ -1627,10 +1911,12 @@ class FetchEmails extends Command
// }
$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];
$name_parts = Customer::parseName($item->personal);
if (!empty($name_parts['first_name'])) {
$data['first_name'] = $name_parts['first_name'];
}
if (!empty($name_parts['last_name'])) {
$data['last_name'] = $name_parts['last_name'];
}
}
Customer::create($item->mail, $data);

View File

@@ -49,7 +49,9 @@ class GenerateVars extends Command
//$filesystem = new Filesystem();
//$file_path = public_path('js/vars.js');
$file_path = storage_path('app/public/js/vars.js');
//$file_path = '/public/js/builds/vars.js';
$relative_path = 'js/builds/vars.js';
$file_path = public_path($relative_path);
$content = view('js/vars', $params)->render();
@@ -57,25 +59,34 @@ class GenerateVars extends Command
// https://github.com/freescout-help-desk/freescout/issues/4369
$content = preg_replace_callback(
"#(:[ ]*\")(.*)(\"[,\r\n])#",
function($v) {
function ($v) {
return $v[1].str_replace('"', '\"', $v[2]).$v[3];
},
$content
);
// Save vars only if content has changed.
try {
if (\Storage::exists('js/vars.js')) {
$old_content = \Storage::get('js/vars.js');
if (!\File::exists(dirname($file_path))) {
\File::makeDirectory(dirname($file_path), \Helper::DIR_PERMISSIONS);
}
// Save vars only if content has changed.
if (\File::exists($file_path)) {
$old_content = \File::get($file_path);
if ($content != $old_content) {
\Storage::put('js/vars.js', $content);
\File::put($file_path, $content);
}
} else {
\Storage::put('js/vars.js', $content);
\File::put($file_path, $content);
}
$this->info("Created: ".substr($file_path, strlen(base_path())+1));
// Backward compatibility.
// Before vars.js was stored in /storage/app/public/js/vars.js
\Storage::put('js/vars.js', $content);
$this->info("Created: /".$relative_path);
} catch (\Exception $e) {
$msg = "Error occurred saving /storage/app/public/js/vars.js. ".\Helper::formatException($e);
$msg = "Error occurred saving $file_path. ".\Helper::formatException($e);
\Log::error($msg);
$this->error($msg);
}

View File

@@ -20,6 +20,35 @@ class LogsMonitor extends Command
*/
protected $description = 'Send new log records by email';
/**
* Default minimum identical-error occurrences required for
* `fetch_errors` entries to be included in the digest. Used when
* the `alert_logs_fetch_min_occurrences` option is not set. The
* default is 1, which matches upstream behaviour (no filtering);
* raise it via the alerts settings page to suppress transient
* single-shot fetch errors.
*/
const FETCH_ERRORS_MIN_OCCURRENCES_DEFAULT = 1;
/**
* Substrings that mark a `fetch_errors` row as transient. Rows
* matching any of these are dropped regardless of count, unless
* they appear at least the configured min-occurrences times.
*/
const FETCH_ERRORS_TRANSIENT_PATTERNS = [
'connection setup failed',
'failed to fetch messages',
'failed to authenticate',
'Connection closed',
'Connection reset',
'Connection timed out',
'Connection refused',
'stream_socket_client',
'SSL operation failed',
'Temporary failure',
'no response',
];
/**
* Create a new command instance.
*
@@ -62,6 +91,8 @@ class LogsMonitor extends Command
->where('created_at', '<', $now->toDateTimeString())
->get();
$logs = $this->filterTransientFetchErrors($logs);
if (!count($logs)) {
$this->line('['.date('Y-m-d H:i:s').'] No new log records found for the last '.$options['alert_logs_period']);
@@ -72,18 +103,18 @@ class LogsMonitor extends Command
$text = 'Logs having new records for the last '.$options['alert_logs_period'].':<ul>';
foreach ($names as $name) {
$text .= '<li>';
$text .= '<strong>'.\App\ActivityLog::getLogTitle($name).'</strong>';
$text .= '<strong>'.htmlspecialchars(\App\ActivityLog::getLogTitle($name)).'</strong>';
$text .= '</li>';
}
$text .= '</ul>';
foreach ($names as $name) {
$text .= '<br/><strong>'.\App\ActivityLog::getLogTitle($name).'</strong><br/>';
$text .= '<br/><strong>'.htmlspecialchars(\App\ActivityLog::getLogTitle($name)).'</strong><br/>';
foreach ($logs as $log) {
if ($log->log_name != $name) {
continue;
}
$text .= '● ['.$log->created_at.'] '.$log->getEventDescription().' <code>'.$log->properties.'</code><br/>';
$text .= '● ['.$log->created_at.'] '.htmlspecialchars($log->getEventDescription()).' <code>'.htmlspecialchars($log->properties).'</code><br/>';
}
}
// Send alert.
@@ -93,4 +124,87 @@ class LogsMonitor extends Command
$this->info('['.date('Y-m-d H:i:s').'] Monitoring finished');
}
/**
* Drop transient single-shot fetch errors. A fetch_errors row is
* considered transient when its error message matches one of the
* known transient patterns AND the same message appears fewer
* than the configured min-occurrences threshold within the window.
*/
protected function filterTransientFetchErrors($logs)
{
$threshold = (int) \Option::get(
'alert_logs_fetch_min_occurrences',
self::FETCH_ERRORS_MIN_OCCURRENCES_DEFAULT
);
if ($threshold <= 1) {
return $logs;
}
$fetchLogName = \App\ActivityLog::NAME_EMAILS_FETCHING;
$counts = [];
foreach ($logs as $log) {
if ($log->log_name !== $fetchLogName) {
continue;
}
$key = $this->fetchErrorFingerprint($log);
$counts[$key] = ($counts[$key] ?? 0) + 1;
}
$kept = [];
$dropped = 0;
foreach ($logs as $log) {
if ($log->log_name === $fetchLogName) {
$message = $this->fetchErrorMessage($log);
if ($this->isTransientFetchError($message)) {
$key = $this->fetchErrorFingerprint($log);
if (($counts[$key] ?? 0) < $threshold) {
$dropped++;
continue;
}
}
}
$kept[] = $log;
}
if ($dropped > 0) {
$this->line('['.date('Y-m-d H:i:s').'] Suppressed '.$dropped.' transient fetch_errors entries (threshold '.$threshold.')');
}
return collect($kept);
}
protected function fetchErrorMessage($log): string
{
$props = $log->properties;
if (is_object($props) && method_exists($props, 'toArray')) {
$props = $props->toArray();
} elseif (is_string($props)) {
$decoded = json_decode($props, true);
$props = is_array($decoded) ? $decoded : [];
}
return (string) ($props['error'] ?? '');
}
protected function fetchErrorFingerprint($log): string
{
$message = $this->fetchErrorMessage($log);
// Strip the trailing "; File: /path (line)" so the same error
// from different stack frames still groups together.
$message = preg_replace('/;\s*File:.*$/s', '', $message);
return trim($message);
}
protected function isTransientFetchError(string $message): bool
{
$lower = strtolower($message);
foreach (self::FETCH_ERRORS_TRANSIENT_PATTERNS as $needle) {
if (strpos($lower, strtolower($needle)) !== false) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,96 @@
<?php
/**
* php artisan module:migrate modulename.
*
* Replaces the command from nwidart/laravel-modules, which passes the module's
* migrations folder to the migrate command after removing base_path() from it.
* Module::getPath() is a realpath(), so on installations where the Modules
* folder is a symlink leading outside of the application folder (a common
* container layout: /var/www/html/Modules -> /data/Modules) base_path() does
* not occur in that path. The value stays absolute, migrate prefixes
* base_path() to it anyway (Illuminate\Database\Console\Migrations\
* BaseCommand::getMigrationPaths()), the resulting folder does not exist and
* the migration reports "Nothing to migrate" with a zero exit code.
*
* https://github.com/freescout-help-desk/freescout/issues/5569
*/
namespace App\Console\Commands;
use Nwidart\Modules\Commands\MigrateCommand;
use Nwidart\Modules\Migrations\Migrator;
use Nwidart\Modules\Module;
class ModuleMigrate extends MigrateCommand
{
/**
* Run the migrations of the specified module.
*
* @param Module $module
*/
protected function migrate(Module $module)
{
$path = $this->getMigrationPath($module);
if ($this->option('subpath')) {
$path = $path.'/'.$this->option('subpath');
}
$this->call('migrate', [
'--path' => $path,
'--database' => $this->option('database'),
'--pretend' => $this->option('pretend'),
'--force' => $this->option('force'),
]);
if ($this->option('seed')) {
$this->call('module:seed', ['module' => $module->getName()]);
}
}
/**
* Get the module's migrations folder relative to base_path(), as the
* migrate command prepends base_path() to whatever --path receives.
*
* @param Module $module
*
* @return string
*/
protected function getMigrationPath(Module $module)
{
$absolute_path = (new Migrator($module))->getPath();
$path = str_replace(base_path(), '', $absolute_path);
if ($path != $absolute_path) {
// The module is located inside of the application folder.
return $path;
}
// The module path is a realpath() leading outside of the application
// folder. Rebuild the path from the folder the module has been scanned
// from, which still contains the symlink and is therefore located
// inside of the application folder.
$module_path = $module->getPath();
if (method_exists($module, 'getScannedPath')
&& $module_path
&& strpos($absolute_path, $module_path) === 0
) {
$scanned_path = str_replace(base_path(), '', $module->getScannedPath());
$scanned_path .= substr($absolute_path, strlen($module_path));
if (is_dir(base_path().DIRECTORY_SEPARATOR.$scanned_path)) {
return $scanned_path;
}
}
// Neither path is located inside of the application folder, so the
// migrate command can not resolve it. Tell the user instead of
// reporting "Nothing to migrate".
$this->error('Module migrations folder is located outside of the application folder: '.$absolute_path);
$this->error('Run "php artisan migrate" to apply the module migrations.');
return $path;
}
}

View File

@@ -62,7 +62,7 @@ class ParseEml extends Command
{
$email = file_get_contents(storage_path('logs/email.eml'));
if (!str_contains($email, "\r\n")){
if (!str_contains($email, "\r\n")) {
$email = str_replace("\n", "\r\n", $email);
}

View File

@@ -10,8 +10,6 @@ use App\Option;
class Kernel extends ConsoleKernel
{
const FETCH_MAX_EXECUTION_TIME = 30; // minutes
/**
* The Artisan commands provided by your application.
*
@@ -127,8 +125,8 @@ class Kernel extends ConsoleKernel
$mutex_name = \Cache::get('fetch_mutex_name') ?? '';
// 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.
// it means the mutex had expired after 'fetch_max_execution_time'
// and the existing command(s) is running longer than '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));
@@ -141,6 +139,11 @@ class Kernel extends ConsoleKernel
// So here we are forcefully removing the mutex. Otherwise mutex will live for 24 hours.
if (\Cache::has($mutex_name)) {
\Cache::forget($mutex_name);
// We could not remove the mutex from cache - something is wrong with permissions.
if (\Cache::has($mutex_name)) {
\Log::error('[schedule:run] Could not remove fetch mutex from cache (cache key: '.$mutex_name.'). Check file permissions.');
}
}
}
}
@@ -153,7 +156,7 @@ class Kernel extends ConsoleKernel
// 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 */)
->withoutOverlapping($expiresAt = (int)config('app.fetch_max_execution_time') /* minutes */)
->sendOutputTo(storage_path().'/logs/fetch-emails.log');
switch (config('app.fetch_schedule')) {
@@ -182,7 +185,7 @@ class Kernel extends ConsoleKernel
$fetch_command->everyMinute();
break;
}
\Cache::put('fetch_mutex_name', $fetch_command->mutexName(), self::FETCH_MAX_EXECUTION_TIME);
\Cache::put('fetch_mutex_name', $fetch_command->mutexName(), (int)config('app.fetch_max_execution_time'));
$schedule = \Eventy::filter('schedule', $schedule);
@@ -243,8 +246,14 @@ class Kernel extends ConsoleKernel
return true;
})
->mutexName();
if (\Cache::get($mutex_name)) {
if (\Cache::has($mutex_name)) {
\Cache::forget($mutex_name);
// We could not remove the mutex from cache - something is wrong with permissions.
if (\Cache::has($mutex_name)) {
\Log::error('[schedule:run] Could not remove queue:work mutex from cache (cache key: '.$mutex_name.'). Check file permissions.');
}
}
}
}

View File

@@ -14,6 +14,7 @@ use App\Events\UserReplied;
use App\Events\ConversationStatusChanged;
use App\Events\ConversationUserChanged;
use App\Events\ConversationCustomerChanged;
use App\Events\UserMovedConversation;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Input;
use Watson\Rememberable\Rememberable;
@@ -34,6 +35,11 @@ class Conversation extends Model
*/
const SUBJECT_LENGTH = 80;
/**
* Subject max length.
*/
const SUBJECT_MAXLENGTH = 998;
/**
* Conversation reply undo timeout in seconds.
* Value has to be larger than close_after in fsFloatingAlertsInit.
@@ -73,7 +79,8 @@ class Conversation extends Model
const STATUS_PENDING = 2;
const STATUS_CLOSED = 3;
const STATUS_SPAM = 4;
// Not used
// Not used in conversations.
const STATUS_NOCHANGE = 6;
//const STATUS_OPEN = 5;
public static $statuses = [
@@ -83,6 +90,13 @@ class Conversation extends Model
self::STATUS_SPAM => 'spam',
//self::STATUS_OPEN => 'open',
];
public static $standard_statuses = [];
// Just to improve performance.
public static $status_names = [];
public static $get_status_callbacks = [];
/**
* https://glyphicons.bootstrapcheatsheets.com/.
@@ -284,6 +298,7 @@ class Conversation extends Model
/**
* Cached mailbox.
*
* @return [type] [description]
*/
public function mailbox_cached()
@@ -390,12 +405,17 @@ class Conversation extends Model
*
* @return Collection
*/
public function getThreads($skip = null, $take = null, $types = [])
public function getThreads($skip = null, $take = null, $types = [], $states = [Thread::STATE_PUBLISHED])
{
$query = $this->threads()
->where('state', Thread::STATE_PUBLISHED)
->orderBy('created_at', 'desc');
if (count($states) == 1 && !empty($states[0])) {
$query->where('state', $states[0]);
} else {
$query->whereIn('state', $states);
}
if (!is_null($skip)) {
$query->skip($skip);
}
@@ -457,6 +477,50 @@ class Conversation extends Model
return $query->first();
}
/**
* When "waiting_since_as_first_unanswered_customer_message" mode is enabled,
* the $last_reply_at field does not store time of each customer reply,
* But "last_customer_reply_at" always stores the time of the last customer reply.
*/
public function getLastCustomerReplyAt()
{
if ($this->last_customer_reply_at) {
return $this->last_customer_reply_at;
}
if ($this->last_reply_from == self::PERSON_CUSTOMER) {
return $this->last_reply_at;
} else {
// Undefined.
return null;
}
}
// Takes into account "app.waiting_since_as_first_unanswered_customer_message".
// And also also sets "last_customer_reply_at".
// $this->last_reply_from MUST store previous value.
public function setLastReplyAt($value, $new_last_reply_from)
{
if (\Helper::isCarbon($value)) {
$value = $date->format('Y-m-d H:i:s');
}
if (config('app.waiting_since_as_first_unanswered_customer_message')) {
// Treat Waiting Since column as "Time of the first unanswered customer message"
// instead of "Time of the last customer activity".
// https://github.com/freescout-help-desk/freescout/issues/5225
if ($this->last_reply_from != Conversation::PERSON_CUSTOMER) {
$this->last_reply_at = $value;
}
} else {
$this->last_reply_at = $value;
}
// "last_customer_reply_at" always stores the time of the last customer reply.
if ($new_last_reply_from == Conversation::PERSON_CUSTOMER) {
$this->last_customer_reply_at = $value;
}
}
/**
* Set preview text.
*
@@ -490,7 +554,7 @@ class Conversation extends Model
$title .= '<br/>'.User::dateFormat($this->created_at, 'M j, Y H:i');
} else {
$person = '';
if (!empty(self::$persons[$this->last_reply_from])) {
if (!empty(self::$persons[$this->last_reply_from ?? ''])) {
$person = __(ucfirst(self::$persons[$this->last_reply_from]));
}
$title = __('Last reply by :person', ['person' => $person]);
@@ -506,22 +570,22 @@ class Conversation extends Model
public function isActive()
{
return $this->status == self::STATUS_ACTIVE;
return $this->getMainStatus() == self::STATUS_ACTIVE;
}
public function isPending()
{
return $this->status == self::STATUS_PENDING;
return $this->getMainStatus() == self::STATUS_PENDING;
}
public function isSpam()
{
return $this->status == self::STATUS_SPAM;
return $this->getMainStatus() == self::STATUS_SPAM;
}
public function isClosed()
{
return $this->status == self::STATUS_CLOSED;
return $this->getMainStatus() == self::STATUS_CLOSED;
}
public function isPublished()
@@ -533,7 +597,7 @@ class Conversation extends Model
{
return $this->state == self::STATE_DRAFT;
}
/**
* Get status name.
*
@@ -545,7 +609,7 @@ class Conversation extends Model
}
/**
* Convert status code to name.
* Convert status ID to name.
*
* @param int $status
*
@@ -553,31 +617,47 @@ class Conversation extends Model
*/
public static function statusCodeToName($status)
{
$name = '';
if (count(self::$get_status_callbacks)) {
$locale = app()->getLocale();
if (!empty(self::$status_names[$locale][$status])) {
return self::$status_names[$locale][$status];
}
}
switch ($status) {
case self::STATUS_ACTIVE:
return __('Active');
$name = __('Active');
break;
case self::STATUS_PENDING:
return __('Pending');
$name = __('Pending');
break;
case self::STATUS_CLOSED:
return __('Closed');
$name = __('Closed');
break;
case self::STATUS_SPAM:
return __('Spam');
$name = __('Spam');
break;
// case self::STATUS_OPEN:
// return __('Open');
// $name = __('Open');
// break;
default:
return '';
break;
//default:
//break;
}
if (count(self::$get_status_callbacks)) {
$name = \Eventy::filter('conversation.status_name', $name, $status);
self::$status_names[$locale][$status] = $name;
}
return $name;
}
/**
@@ -608,13 +688,55 @@ class Conversation extends Model
}
}
public function getStatus()
public function getStatusAttribute($value)
{
if (array_key_exists($this->status, self::$statuses)) {
return $this->status;
} else {
return self::STATUS_ACTIVE;
// Use custom callback for better performance.
//return \Eventy::filter('conversation.get_status', $value, $this);
if (count(self::$get_status_callbacks)) {
foreach (self::$get_status_callbacks as $callback) {
if (is_callable($callback)) {
$value = $callback($value, $this);
}
}
}
return $value;
}
public function setStatusAttribute($status)
{
$status = \Eventy::filter('conversation.set_status', $status, $this);
$this->attributes['status'] = $status;
}
// Get normalized status.
public function getMainStatus()
{
return self::toMainStatus($this->status);
}
public static function isStandardStatus($status)
{
return is_numeric($status) && (int)$status < 10;
}
public static function toMainStatus($status)
{
return (int)substr($status.'', 0, 1);
}
public static function compareStatus($status, $status_list)
{
if (is_array($status_list)) {
return in_array(self::toMainStatus($status), $status_list);
} else {
return self::toMainStatus($status) == (int)$status_list;
}
}
public function hasStatus($list)
{
return in_array($this->getMainStatus(), $list);
}
/**
@@ -622,20 +744,83 @@ class Conversation extends Model
*
* @param int $status
*/
public function setStatus($status, $user = null)
public function setStatus($status, $user = null, $update_folder = true)
{
$now = date('Y-m-d H:i:s');
$this->status = $status;
$this->updateFolder();
if ($update_folder) {
$this->updateFolder();
}
$this->user_updated_at = $now;
if ($user && $status == self::STATUS_CLOSED) {
if ($user && self::toMainStatus($status) == self::STATUS_CLOSED) {
$this->closed_by_user_id = $user->id;
$this->closed_at = $now;
}
}
// Override self::$statuses.
public static function setStatuses($statuses)
{
self::$statuses = $statuses;
}
public static function getStatuses($exclude_ids = [])
{
$statuses = self::$statuses;
if (count($exclude_ids)) {
$statuses = array_diff_key($statuses, array_flip($exclude_ids));
}
return $statuses;
}
public static function getStandardStatuses()
{
return self::$standard_statuses ?: self::$statuses;
}
// Returns array of status ids as keys and names as values.
public static function getStatusesWithNames($exclude_ids = [])
{
$statuses = self::$statuses;
if (count($exclude_ids)) {
$statuses = array_diff_key($statuses, array_flip($exclude_ids));
}
foreach ($statuses as $status_id => $status_code) {
$statuses[$status_id] = self::statusCodeToName($status_id);
}
return $statuses;
}
public static function statusCode($status_id)
{
return self::$statuses[$status_id] ?? '';
}
public function getStatusIcon()
{
return self::$status_icons[$this->getMainStatus()].'';
}
public static function statusIcon($status)
{
//return \Eventy::filter('conversation.get_status_icon', self::$status_icons[$this->getStatus()], $this).'';
return self::$status_icons[self::toMainStatus($status)].'';
}
public function getStatusClass()
{
//return \Eventy::filter('conversation.get_status_class', self::$status_classes[$this->getStatus()], $this).'';
return self::$status_classes[$this->getMainStatus()].'';
}
public static function statusClass($status)
{
return self::$status_classes[self::toMainStatus($status)].'';
}
/**
* Set conversation user and all related fields.
*
@@ -664,6 +849,22 @@ class Conversation extends Model
}
}
public static function addStatusCondition($query, $status_list, $table_name = 'conversations')
{
$query_changed = \Eventy::filter('conversations.query_status_in', false, $query, $status_list, $table_name);
if (!$query_changed) {
if (is_array($status_list)) {
if (count($status_list) == 1) {
$query->where('conversations.status', $status_list[0]);
} else {
$query->whereIn('conversations.status', $status_list);
}
} else {
$query->where('conversations.status', $status_list);
}
}
}
/**
* Get next active conversation.
*
@@ -787,9 +988,9 @@ class Conversation extends Model
$folder_type = Folder::TYPE_DRAFTS;
} elseif ($this->state == self::STATE_DELETED) {
$folder_type = Folder::TYPE_DELETED;
} elseif ($this->status == self::STATUS_SPAM) {
} elseif ($this->isSpam()) {
$folder_type = Folder::TYPE_SPAM;
} elseif ($this->status == self::STATUS_CLOSED) {
} elseif ($this->isClosed()) {
$folder_type = Folder::TYPE_CLOSED;
} elseif ($this->user_id) {
$folder_type = Folder::TYPE_ASSIGNED;
@@ -944,7 +1145,17 @@ class Conversation extends Model
*/
public function getStatusColor()
{
return self::$status_colors[$this->status];
return self::$status_colors[$this->getMainStatus()].'';
}
public function statusColor($status)
{
return self::$status_colors[self::toMainStatus($status)].'';
}
public static function statusName($status)
{
return self::statusCodeToName($status);
}
/**
@@ -1171,9 +1382,12 @@ class Conversation extends Model
* Replace vars in signature.
* `data` contains extra info which can be used to build signature.
*/
public function getSignatureProcessed($data = [], $escape = false)
public function getSignatureProcessed($data = [], $escape = false, $mailbox = null)
{
$replaced_text = $this->replaceTextVars($this->mailbox->signature, $data, $escape);
if (!$mailbox) {
$mailbox = $this->mailbox;
}
$replaced_text = $this->replaceTextVars($mailbox->signature, $data, $escape);
// https://github.com/freescout-helpdesk/freescout/security/advisories/GHSA-fffc-phh8-5h4v
$replaced_text = \Helper::stripDangerousTags($replaced_text);
@@ -1266,7 +1480,14 @@ class Conversation extends Model
*/
public function moveToMailbox($mailbox, $user)
{
$prev_mailbox = $this->mailbox;
$prev_mailbox = clone $this->mailbox;
// Make conversation Unassigned if current assignee does not have
// access to the target mailbox.
// https://github.com/freescout-help-desk/freescout/issues/5333
if ($this->user_id && $this->user && !$this->user->can('view', $mailbox)) {
$this->changeUser(self::USER_UNASSIGNED, $user, $create_thread = true);
}
foreach ($this->folders as $folder) {
// Process indirect folders.
@@ -1327,6 +1548,8 @@ class Conversation extends Model
'user_id' => $this->user_id,
'state' => Thread::STATE_PUBLISHED,
'action_type' => Thread::ACTION_TYPE_MOVED_FROM_MAILBOX,
// Store previous mailbox ID in action_data.
'action_data' => $prev_mailbox->id,
'source_via' => Thread::PERSON_USER,
'source_type' => Thread::SOURCE_TYPE_WEB,
'customer_id' => $this->customer_id,
@@ -1336,6 +1559,10 @@ class Conversation extends Model
$prev_mailbox->updateFoldersCounters();
$mailbox->updateFoldersCounters();
$this->load('mailbox');
event(new UserMovedConversation($this, /*$this->getLastReply(true),*/ $user->id, $prev_mailbox));
\Eventy::action('conversation.moved', $this, $user, $prev_mailbox);
return true;
@@ -1345,6 +1572,11 @@ class Conversation extends Model
*/
public function mergeConversations($second_conversation, $user)
{
// Do not allow to merge with self.
if ($second_conversation->id == $this->id) {
return false;
}
// Move all threads from old to new conversation.
foreach ($second_conversation->threads as $thread) {
$thread->conversation_id = $this->id;
@@ -1723,52 +1955,74 @@ class Conversation extends Model
/**
* Get information on viewers for conversation table.
* Returns users in "users" key with the current user excluded: replying users first, then viewing users.
*/
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 = [];
// Skip stale records not yet removed by freescout:check-conv-viewers.
// Dates have 'Y-m-d H:i:s' format, so they can be compared as strings.
// https://github.com/freescout-help-desk/freescout/pull/5670
$min_date = \Carbon\Carbon::now()->subSeconds(30)->toDateTimeString();
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;
}
if (empty($viewers_cache[$conversation->id]) || !is_array($viewers_cache[$conversation->id])) {
continue;
}
$replying = [];
$viewing = [];
foreach ($viewers_cache[$conversation->id] as $user_id => $viewer) {
if (in_array($user_id, $exclude_user_ids)
|| empty($viewer['t'])
|| $viewer['t'] < $min_date
) {
continue;
}
// 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;
$item = [
'user' => null,
'user_id' => $user_id,
'replying' => !empty($viewer['r']),
];
if ($item['replying']) {
$replying[] = $item;
} else {
$viewing[] = $item;
}
$user_ids[] = $user_id;
}
// Replying viewers go first.
$list = array_merge($replying, $viewing);
if ($list) {
// First viewer is kept on the top level for backward compatibility.
$viewers[$conversation->id] = $list[0];
$viewers[$conversation->id]['users'] = $list;
}
}
// 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();
$users = User::select($fields)->whereIn('id', $user_ids)->get()->keyBy('id');
foreach ($viewers as $i => $viewer) {
foreach ($users as $user) {
if ($user->id == $viewer['user_id']) {
$viewers[$i]['user'] = $user;
foreach ($viewers as $conversation_id => $viewer) {
foreach ($viewer['users'] as $i => $item) {
if (!empty($users[$item['user_id']])) {
$viewers[$conversation_id]['users'][$i]['user'] = $users[$item['user_id']];
} else {
// User not found in DB.
unset($viewers[$conversation_id]['users'][$i]);
}
}
// Remove not found users.
$viewers[$conversation_id]['users'] = array_values($viewers[$conversation_id]['users']);
if (!$viewers[$conversation_id]['users']) {
unset($viewers[$conversation_id]);
continue;
}
$first_user = $viewers[$conversation_id]['users'][0];
$viewers[$conversation_id]['user'] = $first_user['user'];
$viewers[$conversation_id]['user_id'] = $first_user['user_id'];
$viewers[$conversation_id]['replying'] = $first_user['replying'];
}
}
return $viewers;
@@ -1798,11 +2052,15 @@ class Conversation extends Model
\Eventy::action('conversation.state_changed', $this, $user, $prev_state);
}
public function changeStatus($new_status, $user, $create_thread = true)
public function changeStatus($new_status, $user = null, $create_thread = true)
{
if (!array_key_exists($new_status, self::$statuses)) {
if (!array_key_exists((int)$new_status, self::$statuses)) {
return;
}
if (!$user) {
$user = new User();
}
$prev_status = $this->status;
@@ -1822,12 +2080,12 @@ class Conversation extends Model
// 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->created_by_user_id = $user ? $user->id : null;
$thread->save();
}
event(new ConversationStatusChanged($this));
\Eventy::action('conversation.status_changed', $this, $user, $changed_on_reply = false, $prev_status);
\Eventy::action('conversation.status_changed', $this, $user ?? null, $changed_on_reply = false, $prev_status);
}
public function changeUser($new_user_id, $user, $create_thread = true)
@@ -1904,6 +2162,8 @@ class Conversation extends Model
{
\Eventy::action('conversations.before_delete_forever', $conversation_ids);
$folder_ids = [];
//$conversation_ids = $conversations->pluck('id')->toArray();
for ($i=0; $i < ceil(count($conversation_ids) / \Helper::IN_LIMIT); $i++) {
@@ -1913,6 +2173,20 @@ class Conversation extends Model
$thread_ids = Thread::whereIn('conversation_id', $ids)->pluck('id')->toArray();
Attachment::deleteByThreadIds($thread_ids);
// Collect folders IDs.
$folder_ids = array_merge($folder_ids, ConversationFolder::whereIn('conversation_id', $ids)
->distinct()
->pluck('id')
->toArray()
);
$folder_ids = array_unique($folder_ids);
$folder_ids = array_merge($folder_ids, Conversation::whereIn('id', $ids)
->distinct()
->pluck('folder_id')
->toArray()
);
$folder_ids = array_unique($folder_ids);
// Observers do not react on this kind of deleting.
// Delete threads.
@@ -1923,8 +2197,22 @@ class Conversation extends Model
// Delete conversations.
Conversation::whereIn('id', $ids)->delete();
// Delete links to folders.
ConversationFolder::whereIn('conversation_id', $ids)->delete();
}
// Update folders counters.
for ($i=0; $i < ceil(count($folder_ids) / \Helper::IN_LIMIT); $i++) {
$ids = array_slice($folder_ids, $i*\Helper::IN_LIMIT, \Helper::IN_LIMIT);
// Update counters.
$folders = Folder::whereIn('id', $ids)->get();
foreach ($folders as $folder) {
$folder->updateCounters();
}
}
}
/**
@@ -2035,12 +2323,20 @@ class Conversation extends Model
$new_attachment->save();
try {
$attachment_file = new \Illuminate\Http\UploadedFile(
$attachment->getLocalFilePath(), $attachment->file_name,
null, null, true
);
$content_stream = '';
$attachment_file = null;
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, '', $attachment_file);
if (\Helper::isLocalStorage()) {
$attachment_file = new \Illuminate\Http\UploadedFile(
$attachment->getLocalFilePath(), $attachment->file_name,
null, null, true
);
} else {
// File in remote storage.
$content_stream = $attachment->getFileStream();
}
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, $content_stream, $attachment_file);
if (!empty($file_info['file_dir'])) {
$new_attachment->file_dir = $file_info['file_dir'];
@@ -2079,7 +2375,8 @@ class Conversation extends Model
// return self::$email_history_codes[(int)$this->email_history] ?? 'global';
// }
public static function getEmailHistoryName($code) {
public static function getEmailHistoryName($code)
{
$label = '';
switch ($code) {
@@ -2213,8 +2510,8 @@ class Conversation extends Model
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);
\App\Events\RealtimeMailboxNewThread::dispatchSelf($conversation->mailbox_id, $thread->id, (int)$conversation->isChat());
\App\Events\RealtimeChat::dispatchSelf($conversation->mailbox_id, $thread->id, (int)$conversation->isChat());
}
public static function getConvTableSorting($request = null)
@@ -2230,8 +2527,7 @@ class Conversation extends Model
$result = \Eventy::filter('conversations.table_sorting', $result);
if (
!empty($request->sorting['sort_by']) && !empty($request->sorting['order']) &&
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'])
) {
@@ -2287,10 +2583,11 @@ class Conversation extends Model
$query->where('conversations.subject', $like_op, $like)
->orWhere('conversations.customer_email', $like_op, $like)
->orWhere('conversations.'.self::numberFieldName(), $q_int)
->orWhere('conversations.id', $q_int)
// https://github.com/freescout-help-desk/freescout/issues/5298
//->orWhere('conversations.id', $q_int)
->orWhere('customers.first_name', $like_op, $like)
->orWhere('customers.last_name', $like_op, $like)
->orWhere(\Helper::isPgSql() ? \DB::raw('(customers.first_name || \' \' || customers.last_name)') : \DB::raw('CONCAT(customers.first_name, " ", customers.last_name)'), $like_op, $like)
->orWhere(\Helper::isPgSql() ? \DB::raw('('.\DB::getTablePrefix().'customers.first_name || \' \' || '.\DB::getTablePrefix().'customers.last_name)') : \DB::raw('CONCAT('.\DB::getTablePrefix().'customers.first_name, " ", '.\DB::getTablePrefix().'customers.last_name)'), $like_op, $like)
->orWhere('threads.body', $like_op, $like)
->orWhere('threads.from', $like_op, $like)
->orWhere('threads.to', $like_op, $like)
@@ -2315,15 +2612,10 @@ class Conversation extends Model
->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['status']) && is_array($filters['status'])) {
self::addStatusCondition($query_conversations, $filters['status']);
}
if (!empty($filters['state'])) {
if (!empty($filters['state']) && is_array($filters['state'])) {
if (count($filters['state']) == 1) {
// = is faster than IN.
$query_conversations->where('conversations.state', '=', $filters['state'][0]);
@@ -2374,7 +2666,7 @@ class Conversation extends Model
}
if (!self::queryContainsStr($query_sql, '`customers`.`id`')) {
$query_conversations->leftJoin('customers', 'conversations.customer_id', '=' ,'customers.id');
$query_conversations->leftJoin('customers', 'conversations.customer_id', '=', 'customers.id');
}
$query_conversations = \Eventy::filter('search.conversations.apply_filters', $query_conversations, $filters, $q);
@@ -2464,14 +2756,20 @@ class Conversation extends Model
public static function getChats($mailbox_id, $offset = 0, $limit = self::CHATS_LIST_SIZE+1)
{
$chats = Conversation::where('type', self::TYPE_CHAT)
$query = 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();
->limit($limit);
// Check access.
$user = auth()->user();
if ($user->canSeeOnlyAssignedConversations()) {
$query->where('conversations.user_id', $user->id);
}
$chats = $query->get();
// Preload customers.
if (count($chats)) {
@@ -2491,9 +2789,18 @@ class Conversation extends Model
public function userHasAccessToMailbox($user_id)
{
return MailboxUser::where('mailbox_id', $this->mailbox_id)
$has_access = \Helper::memoryCacheGet('mailbox_access.'.$this->mailbox_id.'_'.$user_id);
if (!is_null($has_access)) {
return $has_access;
}
$has_access = MailboxUser::where('mailbox_id', $this->mailbox_id)
->where('user_id', $user_id)
->exists();
\Helper::memoryCachePut('mailbox_access.'.$this->mailbox_id.'_'.$user_id, $has_access);
return $has_access;
}
public function chatShouldStartNew($mailbox = null)
@@ -2502,12 +2809,31 @@ class Conversation extends Model
$mailbox = $this->mailbox;
}
if (!empty($mailbox->meta['chat_start_new'])
&& ($this->status == Conversation::STATUS_CLOSED
|| $this->state == Conversation::STATE_DELETED)
&& ($this->isClosed() || $this->state == Conversation::STATE_DELETED)
) {
return true;
} else {
return false;
}
}
public function isAssignedToUser($user)
{
return \Eventy::filter('conversation.is_user_assignee', $this->user_id == $user->id, $this, $user->id);
}
// Check if conversation mailbox is archived using caching.
public function isMailboxArchived()
{
$is_archived = \Helper::memoryCacheGet('is_archived.'.$this->mailbox_id);
if (!is_null($is_archived)) {
return $is_archived;
}
$is_archived = $this->mailbox->isArchived();
\Helper::memoryCachePut('is_archived.'.$this->mailbox_id, $is_archived);
return $is_archived;
}
}

View File

@@ -4,6 +4,7 @@ namespace App;
use App\Email;
use App\CustomerChannel;
use App\Thread;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
@@ -494,11 +495,19 @@ class Customer extends Model
}
/**
* Get main email.
* Get emails as string.
*/
public static function getMainEmailStatic($customer_id)
public function getEmailsAsArray()
{
return Email::select('email')->where('customer_id', $customer_id)->pluck('email');
return self::getCustomerEmailsAsArray($this->id);
}
/**
* Get emails as string.
*/
public static function getCustomerEmailsAsArray($customer_id)
{
return Email::select('email')->where('customer_id', $customer_id)->pluck('email')->toArray();
}
/**
@@ -652,7 +661,7 @@ class Customer extends Model
return [[
'value' => '',
'type' => self::PHONE_TYPE_WORK,
]];
],];
} else {
return [];
}
@@ -776,7 +785,7 @@ class Customer extends Model
return [[
'type' => '',
'value' => '',
]];
],];
} else {
return [];
}
@@ -822,7 +831,7 @@ class Customer extends Model
if (!$value || preg_match("/^http(s)?:?\/?\/?$/i", $value)) {
continue;
}
if (!preg_match("/http(s)?:\/\//i", $value)) {
if (!preg_match("/^http(s)?:\/\//i", $value)) {
$value = 'http://'.$value;
}
$websites[] = (string) $value;
@@ -932,6 +941,9 @@ class Customer extends Model
if (!$email) {
return null;
}
$email = Email::sanitizeLength($email);
$email_obj = Email::where('email', $email)->first();
if ($email_obj) {
$customer = $email_obj->customer;
@@ -953,7 +965,7 @@ class Customer extends Model
} else {
$customer = new self();
$email_obj = new Email();
$email_obj->email = $email;
$email_obj->email = Email::sanitizeLength($email);
$new = true;
}
@@ -1009,6 +1021,9 @@ class Customer extends Model
$data['notes'] = $data['background'];
}
// Strip tags.
$data = \Helper::stripTagsFromArray($data, $this->fillable);
if ($replace_data) {
// Replace data.
$data_prepared = $data;
@@ -1142,7 +1157,7 @@ class Customer extends Model
*/
public function url()
{
return route('customers.update', ['id'=>$this->id]);
return route('customers.update', ['id' => $this->id]);
}
/**
@@ -1152,7 +1167,7 @@ class Customer extends Model
*/
public function urlView()
{
return route('customers.conversations', ['id'=>$this->id]);
return route('customers.conversations', ['id' => $this->id]);
}
/**
@@ -1292,6 +1307,10 @@ class Customer extends Model
*/
public function mergeWith(Customer $customer2)
{
if ($this->id == $customer2->id) {
return false;
}
$user = auth()->user();
$customer2->conversations()->update(['customer_id' => $this->id]);
@@ -1306,6 +1325,11 @@ class Customer extends Model
// Move emails.
$customer2->emails()->update(['customer_id' => $this->id]);
// Update customer in threads.
// https://github.com/freescout-help-desk/freescout/issues/5505
Thread::where('customer_id', $customer2->id)->update(['customer_id' => $this->id]);
Thread::where('created_by_customer_id', $customer2->id)->update(['created_by_customer_id' => $this->id]);
// Merge attributes
// if (in_array('phone', $keepAttributes) && !$this->phone) {
// $this->phone = $customerToMerge->phone;
@@ -1341,6 +1365,8 @@ class Customer extends Model
\Eventy::action('customer.merged', $this, $customer2, $user);
$customer2->delete();
return true;
}
public static function mergeTypeValueLists($list1, $list2)
@@ -1378,7 +1404,8 @@ class Customer extends Model
}
$file_name = md5(Hash::make($this->id)).'.jpg';
$dest_path = Storage::path(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name);
$storage_path = self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name;
$dest_path = Storage::disk('local')->path($storage_path);
$dest_dir = pathinfo($dest_path, PATHINFO_DIRNAME);
if (!file_exists($dest_dir)) {
@@ -1392,6 +1419,8 @@ class Customer extends Model
imagejpeg($resized_image, $dest_path, self::PHOTO_QUALITY);
$file_name = \Eventy::filter('customer.save_photo', $file_name, $this, $dest_path, $storage_path);
return $file_name;
}
@@ -1426,10 +1455,23 @@ class Customer extends Model
return $data;
}
$name_parts = explode(' ', $name, 2);
$data['first_name'] = $name_parts[0];
if (!empty($name_parts[1])) {
$data['last_name'] = $name_parts[1];
if (strstr($name, ',')) {
// Smith, John.
// https://github.com/freescout-help-desk/freescout/issues/5074
$name_parts = explode(',', $name, 2);
if (!empty($name_parts[1]) && trim($name_parts[1])) {
$data['first_name'] = trim($name_parts[1]);
$data['last_name'] = trim($name_parts[0]);
} else {
$data['first_name'] = trim($name_parts[0]);
}
} else {
// Normal format.
$name_parts = explode(' ', $name, 2);
$data['first_name'] = $name_parts[0];
if (!empty($name_parts[1])) {
$data['last_name'] = $name_parts[1];
}
}
return $data;
@@ -1465,13 +1507,31 @@ class Customer extends Model
public function setPhotoFromRemoteFile($url)
{
$headers = get_headers($url);
// Sanitize URL.
/*try {
\Helper::sanitizeRemoteUrl($url, true);
} catch (\Exception $e) {
if ($e->getCode() == \Helper::EXCEPTION_UNSAFE_URL) {
\Helper::logException($e, 'Customer::setPhotoFromRemoteFile()');
return false;
} else {
throw $e;
}
}*/
// Quick check for response code 200.
try {
$headers = get_headers($url);
} catch (\Exception $e) {
\Helper::logException($e, 'Customer::setPhotoFromRemoteFile()');
return false;
}
if (!preg_match("/200/", $headers[0])) {
return false;
}
$image_data = \Helper::getRemoteFileContents($url);
// URL sanitizing takes place in getRemoteFileContents().
$image_data = \Helper::getRemoteFileContents($url, $follow_redirects = false);
if (!$image_data) {
return false;
@@ -1563,8 +1623,40 @@ class Customer extends Model
$this->channel_id = $channel_id;
$this->save();
}
$customer_channel = CustomerChannel::where('customer_id', $this->id)
->where('channel', $channel)
->first();
return CustomerChannel::create($this->id, $channel, $channel_id);
if ($customer_channel) {
// Update channel_id.
if ($customer_channel->channel_id != $channel_id) {
try {
$customer_channel->channel_id = $channel_id;
$customer_channel->save();
} catch (\Exception $e) {
// Do nothing.
}
return $customer_channel;
}
} else {
// Create.
return CustomerChannel::create($this->id, $channel, $channel_id);
}
}
// Each customer can have only one record for specific CHANNEL in CustomerChannel table.
public function updateChannelId($channel, $new_channel_id)
{
if (!$channel || !$new_channel_id) {
return;
}
try {
CustomerChannel::where('customer_id', $this->id)
->where('channel', $channel)
->update(['channel_id' => $new_channel_id]);
} catch (\Exception $e) {
// Do nothing.
}
}
public static function getCustomerByChannel($channel, $channel_id)

View File

@@ -18,6 +18,8 @@ class Email extends Model
const TYPE_HOME = 2;
const TYPE_OTHER = 3;
const MAX_LENGTH = 191;
public static $types = [
self::TYPE_WORK => 'work',
self::TYPE_HOME => 'home',
@@ -31,6 +33,14 @@ class Email extends Model
*/
protected $guarded = ['id', 'customer_id'];
public function __construct(array $attributes = [])
{
if (!empty($attributes['email'])) {
$attributes['email'] = self::sanitizeLength($attributes['email']);
}
parent::__construct($attributes);
}
/**
* Get email's customer.
*/
@@ -84,4 +94,9 @@ class Email extends Model
return null;
}
}
public static function sanitizeLength($email_address)
{
return substr($email_address, Email::MAX_LENGTH*(-1));
}
}

View File

@@ -15,7 +15,7 @@ class CustomerCreatedConversation
*
* @return void
*/
public function __construct(Conversation $conversation, Thread $last_thread)
public function __construct(Conversation $conversation, $last_thread)
{
$this->conversation = $conversation;
$this->last_thread = $last_thread;

View File

@@ -69,13 +69,15 @@ class RealtimeChat implements ShouldBroadcastNow
/**
* Helper funciton.
*/
public static function dispatchSelf($mailbox_id)
public static function dispatchSelf($mailbox_id, $thread_id, $is_chat)
{
if (!\Helper::isChatModeAvailable()) {
return;
}
$notification_data = [
'mailbox_id' => $mailbox_id
'mailbox_id' => $mailbox_id,
'thread_id' => $thread_id,
'is_chat' => (int)$is_chat,
];
event(new \App\Events\RealtimeChat($notification_data));
}
@@ -97,6 +99,13 @@ class RealtimeChat implements ShouldBroadcastNow
$payload->chats_html = \View::make('mailboxes/partials/chat_list')->with($template_data)->render();
// Audio notification for chats.
if ((int)$payload->is_chat && $payload->thread_id) {
$payload->audio = [
'thread_id' => $payload->thread_id,
];
}
return $payload;
}
}

View File

@@ -88,30 +88,48 @@ class RealtimeConvNewThread implements ShouldBroadcastNow
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)) {
if (!$user) {
return [];
}
$thread = Thread::find($payload->thread_id);
// Check if user can listen to this event.
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-w668-wq26-6c94
if (!$thread) {
return $payload;
return [];
}
$conversation = $thread->conversation;
if (!$conversation) {
return [];
}
if (!$user->can('view', $conversation)) {
return [];
}
// Add thread html to the payload.
$template_data = [
'conversation' => $thread->conversation,
'mailbox' => $thread->conversation->mailbox,
'conversation' => $conversation,
'mailbox' => $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];
$payload->conversation_user_id = $conversation->user_id;
$payload->conversation_status = $conversation->status;
$payload->conversation_status_class = Conversation::$status_classes[$conversation->status];
$payload->conversation_status_icon = Conversation::$status_icons[$conversation->status];
// Audio notification for chats.
if ($conversation->isChat()) {
$payload->audio = [
'thread_id' => $thread->id,
];
}
return $payload;
}

View File

@@ -2,6 +2,7 @@
namespace App\Events;
use App\Conversation;
use Illuminate\Broadcasting\Channel;
//use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
@@ -110,4 +111,18 @@ class RealtimeConvView implements ShouldBroadcastNow
];
event(new \App\Events\RealtimeConvView($notification_data));
}
// Check access.
public static function processPayload($payload)
{
$user = auth()->user();
$conversation = Conversation::find($payload->conversation_id);
// Check if user can listen to this event.
if (!$user || !$conversation || !$user->can('view', $conversation)) {
return [];
}
return $payload;
}
}

View File

@@ -4,6 +4,7 @@
*/
namespace App\Events;
use App\Conversation;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
@@ -59,4 +60,22 @@ class RealtimeConvViewFinish implements ShouldBroadcastNow
{
return 'conv';
}
// Check access.
// $notification_data = [
// 'conversation_id' => $conversation_id,
// 'user_id' => $user_id,
// ];
public static function processPayload($payload)
{
$user = auth()->user();
$conversation = Conversation::find($payload->conversation_id);
// Check if user can listen to this event.
if (!$user || !$conversation || !$user->can('view', $conversation)) {
return [];
}
return $payload;
}
}

View File

@@ -70,10 +70,12 @@ class RealtimeMailboxNewThread implements ShouldBroadcastNow
/**
* Helper funciton.
*/
public static function dispatchSelf($mailbox_id)
public static function dispatchSelf($mailbox_id, $thread_id, $is_chat)
{
$notification_data = [
'mailbox_id' => $mailbox_id
'mailbox_id' => $mailbox_id,
'thread_id' => $thread_id,
'is_chat' => (int)$is_chat,
];
event(new \App\Events\RealtimeMailboxNewThread($notification_data));
}
@@ -105,6 +107,13 @@ class RealtimeMailboxNewThread implements ShouldBroadcastNow
$payload->folders_html = \View::make('mailboxes/partials/folders')->with($template_data)->render();
// Audio notification for chats.
if ((int)$payload->is_chat && $payload->thread_id) {
$payload->audio = [
'thread_id' => $payload->thread_id,
];
}
return $payload;
}
}

View File

@@ -15,7 +15,7 @@ class UserCreatedConversation
*
* @return void
*/
public function __construct(Conversation $conversation, Thread $last_thread)
public function __construct(Conversation $conversation, $last_thread)
{
$this->conversation = $conversation;
$this->last_thread = $last_thread;

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Events;
use App\Conversation;
use App\Mailbox;
use App\Thread;
class UserMovedConversation
{
public $conversation;
//public $last_thread;
public $caused_by_user_id;
public $from_mailbox;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(Conversation $conversation, /*$last_thread,*/ $caused_by_user_id, Mailbox $from_mailbox)
{
$this->conversation = $conversation;
//$this->last_thread = $last_thread;
$this->caused_by_user_id = $caused_by_user_id;
$this->from_mailbox = $from_mailbox;
}
}

View File

@@ -4,6 +4,8 @@ namespace App\Exceptions;
use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\Auth;
use Throwable;
class Handler extends ExceptionHandler
{
@@ -26,6 +28,26 @@ class Handler extends ExceptionHandler
'password_confirmation',
];
/**
* True while an exception is being reported. Protects against
* recursive reporting (an exception thrown while reporting another one).
*
* @var bool
*/
protected $is_reporting = false;
/**
* Classes/namespaces whose stack frame arguments may contain DB credentials
* (host, username and password are passed as arguments when connecting).
*
* @var array
*/
protected static $sensitive_frame_prefixes = [
'Illuminate\Database\Connectors\\',
'Doctrine\DBAL\Driver\\',
'PDO',
];
/**
* Report or log an exception.
*
@@ -37,7 +59,123 @@ class Handler extends ExceptionHandler
*/
public function report(Exception $exception)
{
parent::report($exception);
// If an exception occurs while another one is being reported
// (for example the database is down and something in the reporting
// chain queries it), do not report it again to avoid an infinite
// loop and gigantic log files.
if ($this->is_reporting) {
return;
}
$this->is_reporting = true;
try {
// Do not write DB credentials from the stack trace to the logs.
$this->maskSensitiveTraceArgs($exception);
parent::report($exception);
} finally {
$this->is_reporting = false;
}
}
/**
* Replace string arguments (which may contain DB host, username and
* password) of the sensitive stack frames with asterisks (the first
* 2 symbols are left untouched), so that they
* are not written to the logs. Applies to the whole chain of previous
* exceptions.
*
* @param \Exception $exception
*
* @return void
*/
protected function maskSensitiveTraceArgs($exception)
{
try {
$property = new \ReflectionProperty(\Exception::class, 'trace');
// Has no effect since PHP 8.1 and is deprecated since PHP 8.5.
if (PHP_VERSION_ID < 80100) {
$property->setAccessible(true);
}
$depth = 0;
while ($exception && $depth < 20) {
$trace = $property->getValue($exception);
if (is_array($trace)) {
$changed = false;
foreach ($trace as $i => $frame) {
if (empty($frame['args']) || !is_array($frame['args'])) {
continue;
}
$class = isset($frame['class']) ? $frame['class'] : '';
foreach (self::$sensitive_frame_prefixes as $prefix) {
if ($class !== '' && strpos($class, $prefix) === 0) {
foreach ($frame['args'] as $arg_index => $arg) {
if (is_string($arg)) {
$trace[$i]['args'][$arg_index] = $this->maskString($arg);
}
}
$changed = true;
break;
}
}
}
if ($changed) {
$property->setValue($exception, $trace);
}
}
$exception = $exception->getPrevious();
$depth++;
}
} catch (Throwable $e) {
// Do nothing.
}
}
/**
* Mask a string: keep the first 2 symbols and replace the rest with asterisks.
* Strings of 2 symbols or less are masked completely.
*
* @param string $string
*
* @return string
*/
protected function maskString($string)
{
if (mb_strlen($string) <= 2) {
return '***';
}
return mb_substr($string, 0, 2).'***';
}
/**
* Get the default context variables for logging.
*
* Same as in Laravel 5.8+: only the user ID is added (no email).
*
* @return array
*/
protected function context()
{
// Try to avoid reading user from DB.
// https://github.com/freescout-help-desk/freescout/issues/5707
$guard = Auth::guard();
$user_id = $guard->getSession()->get($guard->getName());
/*if (!$user_id) {
$user_id = Auth::id();
}*/
try {
return array_filter([
'userId' => $user_id,
]);
} catch (Throwable $e) {
return [];
}
}
/**

View File

@@ -179,7 +179,7 @@ class Folder extends Model
$sorting = Conversation::getConvTableSorting();
if ($sorting['sort_by'] == 'date') {
if ($sorting['order'] != 'desc') {
foreach ($order_by as $block_i => $block) {
foreach ($order_by as $block_i => $block) {
foreach ($block as $field => $order) {
if ($field == 'status') {
unset($order_by[$block_i][$field]);
@@ -224,7 +224,7 @@ class Folder extends Model
public function updateCounters()
{
if (config('app.update_folder_counters_in_background')) {
if(!\Illuminate\Support\Facades\Cache::has("folder_update_lock_{$this->id}")) {
if (!\Illuminate\Support\Facades\Cache::has("folder_update_lock_{$this->id}")) {
\App\Jobs\UpdateFolderCounters::dispatch($this);
}
} else {
@@ -382,13 +382,13 @@ class Folder extends Model
} elseif ($this->type == \App\Folder::TYPE_DELETED) {
return 'user_updated_at';
} else {
return'last_reply_at';
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);
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)
@@ -396,7 +396,7 @@ class Folder extends Model
if (!isset($data['mailbox_id']) || !isset($data['type'])) {
return null;
}
$folder = new Folder ();
$folder = new Folder();
$folder->mailbox_id = $data['mailbox_id'];
$folder->type = $data['type'];

View File

@@ -4,6 +4,11 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Cache\RateLimiter;
use Illuminate\Validation\ValidationException;
use Illuminate\Auth\Events\Lockout;
class ForgotPasswordController extends Controller
{
@@ -20,6 +25,12 @@ class ForgotPasswordController extends Controller
use SendsPasswordResetEmails;
// Max reset password attempts per period.
const THROTTLE_ATTEMPTS = 5;
// Minutes
const THROTTLE_PERIOD = 1;
/**
* Create a new controller instance.
*
@@ -29,4 +40,73 @@ class ForgotPasswordController extends Controller
{
$this->middleware('guest');
}
/**
* Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
*/
public function sendResetLinkEmail(Request $request)
{
$this->validateEmail($request);
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-jvmv-2qcp-7855
if ($this->hasTooManyResetEmailAttempts($request)) {
event(new Lockout($request));
return $this->sendLockoutResponse($request);
}
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$response = $this->broker()->sendResetLink(
$request->only('email')
);
if ($response == Password::RESET_LINK_SENT) {
$this->clearResetEmailAttempts($request);
} else {
$this->incrementResetEmailAttempts($request);
}
// For security purposes always return an identical response regardless of whether the email exists.
// return $response == Password::RESET_LINK_SENT
// ? $this->sendResetLinkResponse($response)
// : $this->sendResetLinkFailedResponse($request, $response);
return $this->sendResetLinkResponse(__('If an account exists for this email, you will receive a password reset link.'));
}
protected function hasTooManyResetEmailAttempts(Request $request)
{
return app(RateLimiter::class)->tooManyAttempts(
$this->throttleKey($request),
self::THROTTLE_ATTEMPTS
);
}
protected function incrementResetEmailAttempts(Request $request)
{
app(RateLimiter::class)->hit(
$this->throttleKey($request),
self::THROTTLE_PERIOD
);
}
protected function clearResetEmailAttempts(Request $request)
{
app(RateLimiter::class)->clear($this->throttleKey($request));
}
protected function throttleKey(Request $request)
{
return strtolower($request->ip()) . '|reset_email_password';
}
protected function sendLockoutResponse(Request $request)
{
throw ValidationException::withMessages([
'email' => [trans('auth.throttle', ['seconds' => self::THROTTLE_PERIOD*60])],
])->status(423);
}
}

View File

@@ -7,6 +7,11 @@ use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Hash;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Cache\RateLimiter;
use Illuminate\Validation\ValidationException;
use Illuminate\Auth\Events\Lockout;
class ResetPasswordController extends Controller
{
@@ -23,6 +28,12 @@ class ResetPasswordController extends Controller
use ResetsPasswords;
// Max reset password attempts per period.
const THROTTLE_ATTEMPTS = 5;
// Minutes
const THROTTLE_PERIOD = 1;
/**
* Where to redirect users after resetting their password.
*
@@ -40,6 +51,46 @@ class ResetPasswordController extends Controller
$this->middleware('guest');
}
/**
* Reset the given user's password.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
*/
public function reset(Request $request)
{
$this->validate($request, $this->rules(), $this->validationErrorMessages());
if ($this->hasTooManyResetAttempts($request)) {
event(new Lockout($request));
return $this->sendLockoutResponse($request);
}
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$response = $this->broker()->reset(
$this->credentials($request), function ($user, $password) {
$this->resetPassword($user, $password);
}
);
if ($response == Password::PASSWORD_RESET) {
$this->clearResetAttempts($request);
} else {
$this->incrementResetAttempts($request);
// When user not found and token is invalid return same error for security reasons.
$response = Password::INVALID_USER;
}
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $response == Password::PASSWORD_RESET
? $this->sendResetResponse($response)
: $this->sendResetFailedResponse($request, $response);
}
/**
* Reset the given user's password.
*
@@ -59,4 +110,37 @@ class ResetPasswordController extends Controller
//$this->guard()->login($user);
}
protected function hasTooManyResetAttempts(Request $request)
{
return app(RateLimiter::class)->tooManyAttempts(
$this->throttleKey($request),
self::THROTTLE_ATTEMPTS
);
}
protected function incrementResetAttempts(Request $request)
{
app(RateLimiter::class)->hit(
$this->throttleKey($request),
self::THROTTLE_PERIOD
);
}
protected function clearResetAttempts(Request $request)
{
app(RateLimiter::class)->clear($this->throttleKey($request));
}
protected function throttleKey(Request $request)
{
return strtolower($request->ip()) . '|reset_password';
}
protected function sendLockoutResponse(Request $request)
{
throw ValidationException::withMessages([
'email' => [trans('auth.throttle', ['seconds' => self::THROTTLE_PERIOD*60])],
])->status(423);
}
}

View File

@@ -61,13 +61,18 @@ class ConversationsController extends Controller
// 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->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();
// Build quiery manually instad of unreadNotifications() in order to use indexes.
//$mark_read_result = $user->unreadNotifications()->where('data', 'like', '%"conversation_id":'.$conversation->id.'%')->update(['read_at' => now()]);
$mark_read_result = $user->morphMany(\Illuminate\Notifications\DatabaseNotification::class, 'notifiable')
->where('conversation_id', $conversation->id)
->whereNull('read_at')
->update(['read_at' => now()]);
if ($mark_read_result) {
$user->clearWebsiteNotificationsCache();
}
}
// Detect folder and redirect if needed
@@ -99,7 +104,10 @@ class ConversationsController extends Controller
\Session::reflash();
return redirect()->away($conversation->url($folder->id, null, $params));
// A user may have no Mine folder in this mailbox (e.g. admins / non-members),
// so the lookup above can return null. Fall back to the conversation's own
// folder instead of throwing "Attempt to read property id on null".
return redirect()->away($conversation->url($folder ? $folder->id : $conversation->folder_id, null, $params));
}
}
}
@@ -117,7 +125,8 @@ class ConversationsController extends Controller
\Session::reflash();
return redirect()->away($conversation->url($folder->id));
// The Mine-folder lookup (or $conversation->folder) can be null; fall back to folder_id.
return redirect()->away($conversation->url($folder ? $folder->id : $conversation->folder_id));
}
//$after_send = $conversation->mailbox->getUserSettings($user->id)->after_send;
@@ -304,12 +313,12 @@ class ConversationsController extends Controller
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']
'replying' => (int)$conv_view[$conversation->id][$viewer->id]['r'],
];
}
}
// Show replying first.
usort($viewers, function($a, $b) {
usort($viewers, function ($a, $b) {
return $b['replying'] <=> $a['replying'];
});
}
@@ -450,6 +459,11 @@ class ConversationsController extends Controller
}
}
if ($request->get('body') && !$thread) {
$thread = new Thread();
$thread->body = \Helper::stripDangerousTags($request->get('body'));
}
$conversation->subject = $subject;
return view('conversations/create', [
@@ -468,7 +482,7 @@ class ConversationsController extends Controller
/**
* Clone conversation.
*/
public function cloneConversation(Request $request, $mailbox_id, $from_thread_id)
public function cloneConversation(Request $request, $mailbox_id, $from_thread_id, $token)
{
$mailbox = Mailbox::findOrFail($mailbox_id);
$this->authorize('view', $mailbox);
@@ -480,6 +494,10 @@ class ConversationsController extends Controller
$orign_conv = $orig_thread->conversation;
$this->authorize('view', $orign_conv);
if (csrf_token() != $token) {
\Session::flash('flash_error_floating', __('Not enough permissions'));
return redirect()->away($orign_conv->url($orign_conv->folder_id));
}
// $thread = $orig_thread->replicate();
// $thread->id = '';
@@ -797,7 +815,7 @@ class ConversationsController extends Controller
}
if ($validator->fails()) {
foreach ($validator->errors()->getMessages()as $errors) {
foreach ($validator->errors()->getMessages() as $errors) {
foreach ($errors as $field => $message) {
$response['msg'] .= $message.' ';
}
@@ -827,7 +845,7 @@ class ConversationsController extends Controller
}
// Check max. message size.
if (!$response['msg']) {
if (!$response['msg'] && !$is_note) {
$max_message_size = (int)config('app.max_message_size');
if ($max_message_size) {
@@ -839,13 +857,17 @@ class ConversationsController extends Controller
$attachments_ids = $this->decodeAttachmentsIds($attachments_ids);
if (count($attachments_ids)) {
$attachments_to_check = Attachment::select('size')->whereIn('id', $attachments_ids)->get();
foreach ($attachments_to_check as $attachment) {
$attachments_query = Attachment::select('size')->whereIn('id', $attachments_ids);
// Skip embedded images.
if (!\Eventy::filter('attachments.embedded.check_size', false)) {
$attachments_query->where('embedded', false);
}
foreach ($attachments_query->get() as $attachment) {
$message_size += (int)$attachment->size;
}
}
if ($message_size > $max_message_size*1024*1024) {
if ($message_size*1.37 > $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']);
}
}
@@ -855,7 +877,7 @@ class ConversationsController extends Controller
// Get attachments info
// Delete removed attachments.
$attachments_info = $this->processReplyAttachments($request);
$attachments_info = $this->processReplyAttachments($request, $thread->id ?? null);
// Determine redirect.
// Must be done before updating current conversation's status or assignee.
@@ -919,7 +941,12 @@ class ConversationsController extends Controller
if ($is_phone && $is_create) {
// Phone.
$phone_customer_data = $this->processPhoneCustomer($request);
$phone_customer_data = $this->processPhoneCustomer($request, $user);
if (!empty($phone_customer_data['msg'])) {
$response['msg'] = $phone_customer_data['msg'];
break;
}
$customer_email = $phone_customer_data['customer_email'];
$customer = $phone_customer_data['customer'];
@@ -957,7 +984,7 @@ class ConversationsController extends Controller
$conversation->status = $request_status ?: $conversation->status;
if (($prev_status != $conversation->status || $is_create)
&& $conversation->status == Conversation::STATUS_CLOSED
&& $conversation->isClosed()
) {
$conversation->closed_by_user_id = $user->id;
$conversation->closed_at = date('Y-m-d H:i:s');
@@ -1137,8 +1164,8 @@ class ConversationsController extends Controller
}
$forwarded_conversation->updateFolder();
$forwarded_conversation->save();
$forwarded_thread = $thread->replicate();
$forwarded_thread->setTo($recipient_email);
$forwarded_conversations[] = $forwarded_conversation;
$forwarded_threads[] = $forwarded_thread;
@@ -1147,8 +1174,8 @@ class ConversationsController extends Controller
// 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);
$thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_NUMBER, $forwarded_conversations[0]->number);
$thread->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_ID, $forwarded_conversations[0]->id);
}
// Conversation history.
@@ -1172,8 +1199,8 @@ class ConversationsController extends Controller
\Eventy::action('thread.before_save_from_request', $thread, $request);
$thread->save();
// Save forwarded thread.
if ($is_forward) {
// Save forwarded threads.
foreach ($forwarded_conversations as $i => $forwarded_conversation) {
$forwarded_thread = $forwarded_threads[$i];
@@ -1188,6 +1215,17 @@ class ConversationsController extends Controller
$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();
// In the current conversation create Forward-notes corresponding to each recipient.
// Forward-note for the first recipient is already created.
if ($i != 0) {
// $thread contains note created in the original conversation.
$forward_note = $thread->replicate();
$forward_note->setTo($forwarded_conversation->customer_email);
$forward_note->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_NUMBER, $forwarded_conversation->number);
$forward_note->setMeta(Thread::META_FORWARD_CHILD_CONVERSATION_ID, $forwarded_conversation->id);
$forward_note->save();
}
}
}
@@ -1344,7 +1382,7 @@ class ConversationsController extends Controller
$show_view_link = false;
}
$flash_vars = ['%tag_start%' => '<strong>', '%tag_end%' => '</strong>', '%view_start%' => '&nbsp;<a href="'.$conversation->url().'">', '%a_end%' => '</a>&nbsp;', '%undo_start%' => '&nbsp;<a href="'.route('conversations.undo', ['thread_id' => $thread->id]).'" class="text-danger">'];
$flash_vars = ['%tag_start%' => '<strong>', '%tag_end%' => '</strong>', '%view_start%' => '&nbsp;<a href="'.$conversation->url().'">', '%a_end%' => '</a>&nbsp;', '%undo_start%' => '&nbsp;<a href="'.route('conversations.undo', ['thread_id' => $thread->id, 'token' => csrf_token()]).'" class="text-danger">'];
if ($is_phone) {
$flash_type = 'warning';
@@ -1359,7 +1397,7 @@ class ConversationsController extends Controller
if ($show_view_link) {
$flash_text = __(':%tag_start%' . $identifier . ' added:%tag_end% :%view_start%View:%a_end%', $flash_vars);
} else {
$flash_text = '<strong>'.__('%identifier% added',['%identifier%'=>$identifier]).'</strong>';
$flash_text = '<strong>'.__('%identifier% added', ['%identifier%' => $identifier]).'</strong>';
}
} elseif ($is_note) {
$flash_type = 'warning';
@@ -1393,16 +1431,18 @@ class ConversationsController extends Controller
}
$conversation = null;
// Conversation does not exist yet.
$new = true;
if (!$response['msg'] && !empty($request->conversation_id)) {
$conversation = Conversation::find($request->conversation_id);
if ($conversation && !$user->can('view', $conversation) && !$user->hasManageMailboxPermission($request->mailbox_id, Mailbox::ACCESS_PERM_ASSIGNED)) {
if ($conversation && !$user->can('view', $conversation) /*&& !$user->hasManageMailboxPermission($request->mailbox_id, Mailbox::ACCESS_PERM_ASSIGNED)*/) {
$response['msg'] = __('Not enough permissions');
} else {
$new = false;
}
}
// Draft saved from "New Conversation" page.
$is_create = false;
if (!empty($request->is_create)) {
$is_create = true;
@@ -1446,12 +1486,23 @@ class ConversationsController extends Controller
}
}
// Make sure that conversation is in Draft state.
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-6ff4-3w2c-mjj8
if (!$response['msg']
&& $conversation
&& !$conversation->isDraft()
&& ($new || $is_create)
) {
$response['msg'] = __('Message has been already sent. Please discard this draft.');
}
// Validation is not needed on draft create, fields can be empty
// Update conversation data.
if (!$response['msg']) {
// Get attachments info
$attachments_info = $this->processReplyAttachments($request);
$attachments_info = $this->processReplyAttachments($request, $thread->id ?? null);
// Conversation
$now = date('Y-m-d H:i:s');
@@ -1475,7 +1526,12 @@ class ConversationsController extends Controller
if ($type == Conversation::TYPE_PHONE) {
// Phone.
$phone_customer_data = $this->processPhoneCustomer($request);
$phone_customer_data = $this->processPhoneCustomer($request, $user);
if (!empty($phone_customer_data['msg'])) {
$response['msg'] = $phone_customer_data['msg'];
break;
}
$customer_email = $phone_customer_data['customer_email'];
$customer = $phone_customer_data['customer'];
@@ -1765,12 +1821,14 @@ class ConversationsController extends Controller
$attachment_copy = $attachment;
}
$attachments[] = [
'id' => encrypt($attachment_copy->id),
'name' => $attachment_copy->file_name,
'size' => $attachment_copy->size,
'url' => $attachment_copy->url(),
];
if ($attachment_copy) {
$attachments[] = [
'id' => encrypt($attachment_copy->id),
'name' => $attachment_copy->file_name,
'size' => $attachment_copy->size,
'url' => $attachment_copy->url(),
];
}
}
}
}
@@ -1829,6 +1887,7 @@ class ConversationsController extends Controller
case 'conversation_change_customer':
$conversation = Conversation::find($request->conversation_id);
$customer_email = $request->customer_email;
$target_customer = Customer::getByEmail($request->customer_email);
if (!$conversation) {
$response['msg'] = __('Conversation not found');
@@ -1840,10 +1899,25 @@ class ConversationsController extends Controller
$response['msg'] = __('Not enough permissions');
}
$conversation->changeCustomer($customer_email, null, $user);
// Allow to change customer when user creates a customer
// while changing conversation's customer
// and APP_LIMIT_USER_CUSTOMER_VISIBILITY is enabled.
if (session()->get('user_created_customer') == $target_customer->id) {
session()->forget('user_created_customer');
} else {
if (!$response['msg'] && $target_customer && !$user->can('view', $target_customer)) {
$response['msg'] = __('Not enough permissions');
}
}
$response['status'] = 'success';
\Session::flash('flash_success_floating', __('Customer changed'));
if (!$response['msg']) {
$result = $conversation->changeCustomer($customer_email, null, $user);
if ($result) {
$response['status'] = 'success';
\Session::flash('flash_success_floating', __('Customer changed'));
}
}
break;
@@ -1876,11 +1950,11 @@ class ConversationsController extends Controller
}
if (!$response['msg']) {
$folder_id = $conversation->getCurrentFolder();
$redirect_url = $this->getRedirectUrlAfterDelete($conversation, $user);
$conversation->deleteToFolder($user);
$response['redirect_url'] = route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $folder_id]);
$response['redirect_url'] = $redirect_url;
$response['status'] = 'success';
@@ -1898,7 +1972,7 @@ class ConversationsController extends Controller
}
if (!$response['msg']) {
$folder_id = $conversation->getCurrentFolder();
$redirect_url = $this->getRedirectUrlAfterDelete($conversation, $user);
$mailbox = $conversation->mailbox;
$conversation->deleteForever();
@@ -1906,7 +1980,7 @@ class ConversationsController extends Controller
// Recalculate only old and new folders
$mailbox->updateFoldersCounters();
$response['redirect_url'] = route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $folder_id]);
$response['redirect_url'] = $redirect_url;
$response['status'] = 'success';
@@ -1972,7 +2046,7 @@ class ConversationsController extends Controller
$thread->body = \Helper::stripDangerousTags($thread->body);
$data = [
'thread' => $thread
'thread' => $thread,
];
$response['html'] = \View::make('conversations/partials/edit_thread')->with($data)->render();
@@ -2128,7 +2202,23 @@ class ConversationsController extends Controller
// 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)) {
if (!$user->isAdmin() && !$user->hasPermission(\App\User::PERM_DELETE_CONVERSATIONS)) {
$response['msg'] = __('Not enough permissions');
return \Response::json($response);
}
// Check access to the mailbox.
$folder = Folder::find($request->folder_id ?? '');
if (!$folder) {
$response['msg'] = __('Folder not found');
return \Response::json($response);
}
if (!in_array($folder->type, [Folder::TYPE_SPAM, Folder::TYPE_DELETED])) {
$response['msg'] = __('Folder not found');
return \Response::json($response);
}
if (!$folder->mailbox->userHasAccess($user->id)) {
$response['msg'] = __('Not enough permissions');
return \Response::json($response);
}
@@ -2139,14 +2229,28 @@ class ConversationsController extends Controller
);
if (empty($response['processed'])) {
$folder = Folder::find($request->folder_id ?? '');
if (!$folder) {
$response['msg'] = __('Folder not found');
if (!$user->isAdmin() && $folder->mailbox && !$folder->mailbox->userHasAccess($user->id)) {
$response['msg'] = __('Not enough permissions');
}
if (!$response['msg']) {
$conversation_ids = Conversation::where('folder_id', $folder->id)->pluck('id')->toArray();
// Do not allow users who can see only assigned conversations
// delete any conversations in the folder.
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-6mhr-m8m9-6q6h
if (!$user->isAdmin() && $user->canSeeOnlyAssignedConversations()) {
// User can see (and selete) only assigned conversations.
$conversation_ids = Conversation::where('folder_id', $folder->id)
->get()
->filter(function ($conversation) use ($user) {
return $conversation->isAssignedToUser($user);
})
->pluck('id')
->toArray();
} else {
$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);
@@ -2221,7 +2325,7 @@ class ConversationsController extends Controller
$response['msg'] = __('Not enough permissions');
}
if (!empty($request->merge_conversation_id) && is_array($request->merge_conversation_id)) {
if (!$response['msg'] && !empty($request->merge_conversation_id) && is_array($request->merge_conversation_id)) {
$sigle_conv = count($request->merge_conversation_id) == 1;
@@ -2268,14 +2372,16 @@ class ConversationsController extends Controller
$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']) {
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();
}
}
}
@@ -2314,7 +2420,7 @@ class ConversationsController extends Controller
case 'merge_search':
$conversation = Conversation::where(Conversation::numberFieldName(), $request->number)->first();
if (!$conversation) {
if (!$conversation || $conversation->id == ($request->cur_conv_id ?? '')) {
$response['msg'] = __('Conversation not found');
}
if (!$response['msg'] && !$user->can('view', $conversation)) {
@@ -2323,7 +2429,7 @@ class ConversationsController extends Controller
if (!$response['msg']) {
$response['html'] = \View::make('conversations/partials/merge_search_result')->with([
'conversation' => $conversation
'conversation' => $conversation,
])->render();
$response['status'] = 'success';
}
@@ -2376,11 +2482,22 @@ class ConversationsController extends Controller
$customer = Customer::getByEmail($request->customer_email);
if ($customer) {
// Previous conversations
$prev_conversations = [];
if (!$user->can('view', $customer)) {
$response['msg'] = __('Not enough permissions');
break;
}
$mailbox = Mailbox::find($request->mailbox_id);
if (!$mailbox || !$mailbox->userHasAccess($user->id)) {
$response['msg'] = __('Not enough permissions');
break;
}
// Previous conversations
$prev_conversations = [];
if ($mailbox && $mailbox->userHasAccess($user->id)) {
$conversation_id = (int)$request->conversation_id ?? 0;
@@ -2572,7 +2689,7 @@ class ConversationsController extends Controller
abort(403);
}
$mailboxes = \Eventy::filter( 'conversations.move_conv.mailboxes', $user->mailboxesCanView() );
$mailboxes = \Eventy::filter('conversations.move_conv.mailboxes', $user->mailboxesCanView());
return view('conversations/ajax_html/move_conv', [
'conversation' => $conversation,
@@ -2737,6 +2854,20 @@ class ConversationsController extends Controller
return $redirect_url;
}
/**
* Where to go after deleting a conversation.
* Follows the user's "after send" setting, as changing the status does: "Next active conversation" opens it,
* anything else shows the folder. Must be called before the conversation is deleted.
*/
public function getRedirectUrlAfterDelete($conversation, $user)
{
if ($conversation->mailbox->getUserSettings($user->id)->after_send == MailboxUser::AFTER_SEND_NEXT) {
return $conversation->urlNext(Conversation::getFolderParam(), Conversation::STATUS_ACTIVE, true);
}
return route('mailboxes.view.folder', ['id' => $conversation->mailbox_id, 'folder_id' => $conversation->getCurrentFolder()]);
}
/**
* Upload files and images.
*/
@@ -2855,6 +2986,9 @@ class ConversationsController extends Controller
$filters_data['customer'] = Customer::find($filters['customer']);
}
//$filters = \Eventy::filter('search.filters', $filters, $filters_data, $mode, $q);
if ($user->canSeeOnlyAssignedConversations()) {
$filters['assigned'] = $user->id;
}
// Remember recent query.
$recent_search_queries = session('recent_search_queries') ?? [];
@@ -2865,13 +2999,27 @@ class ConversationsController extends Controller
}
$conversations = [];
if (\Eventy::filter('search.is_needed', true, 'conversations')) {
$conversations = $this->searchQuery($user, $q, $filters);
// If search string starts with # - try to find conversation by number.
if (\Str::startsWith($q, '#')) {
$conv_number = ltrim($q, '#');
if (is_numeric($conv_number)) {
$conversation = Conversation::where(Conversation::numberFieldName(), $conv_number)->first();
if ($conversation && $user->can('view', $conversation)) {
$conversations[] = $conversation;
}
}
}
if (!count($conversations)) {
$conversations = $this->searchQuery($user, $q, $filters);
}
}
// Jump to the conversation if searching by conversation number.
if (count($conversations) == 1
&& $conversations[0]->number == $q
&& ($conversations[0]->number == $q || $conversations[0]->number == ltrim($q, '#'))
&& empty($filters)
&& !$request->x_embed
) {
@@ -2949,6 +3097,11 @@ class ConversationsController extends Controller
if ($conversations !== '') {
return $conversations;
}
if ($user->canSeeOnlyAssignedConversations()) {
$filters['assigned'] = $user->id;
}
$query_conversations = Conversation::search($q, $filters, $user);
return $query_conversations->paginate(Conversation::DEFAULT_LIST_SIZE);
}
@@ -2989,6 +3142,12 @@ class ConversationsController extends Controller
$filters[$filter] = date('Y-m-d', strtotime($value));
}
break;
case 'status':
case 'state':
if (!is_array($value)) {
unset($filters[$filter]);
}
break;
}
}
@@ -3008,7 +3167,7 @@ class ConversationsController extends Controller
$mailbox_ids = $user->mailboxesIdsCanView();
// Filters
$filters = $this->getSearchFilters($request);;
$filters = $this->getSearchFilters($request);
// Search query
$q = $this->getSearchQuery($request);
@@ -3017,7 +3176,7 @@ class ConversationsController extends Controller
$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)')])
$query_customers = Customer::select(['customers.*', \DB::raw('MAX('.\DB::getTablePrefix().'emails.email)')])
->groupby('customers.id')
->leftJoin('emails', function ($join) {
$join->on('customers.id', '=', 'emails.customer_id');
@@ -3030,7 +3189,7 @@ class ConversationsController extends Controller
$query->where('customers.first_name', $like_op, $like)
->orWhere('customers.last_name', $like_op, $like)
->orWhere(\Helper::isPgSql() ? \DB::raw('(customers.first_name || \' \' || customers.last_name)') : \DB::raw('CONCAT(customers.first_name, " ", customers.last_name)'), $like_op, $like)
->orWhere(\Helper::isPgSql() ? \DB::raw('('.\DB::getTablePrefix().'customers.first_name || \' \' || '.\DB::getTablePrefix().'customers.last_name)') : \DB::raw('CONCAT('.\DB::getTablePrefix().'customers.first_name, " ", '.\DB::getTablePrefix().'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)
@@ -3123,6 +3282,10 @@ class ConversationsController extends Controller
}
}
if ($user->canSeeOnlyAssignedConversations()) {
$query_conversations->where('conversations.user_id', $user->id);
}
if (!empty($request->params['user_id'])) {
$query_conversations->where('conversations.user_id', (int)$request->params['user_id']);
}
@@ -3133,7 +3296,7 @@ class ConversationsController extends Controller
/**
* Process attachments on reply, new conversation, saving draft and forwarding.
*/
public function processReplyAttachments($request)
public function processReplyAttachments($request, $thread_id = null)
{
$has_attachments = false;
$attachments = [];
@@ -3153,7 +3316,18 @@ class ConversationsController extends Controller
) {
$has_attachments = true;
}
Attachment::deleteByIds($attachments_to_remove);
// Sanitize $attachments_to_remove list.
if (count($attachments_to_remove)) {
$attachments_check = Attachment::select('id', 'thread_id')
->whereIn('id', $attachments_to_remove)
->get();
foreach ($attachments_check as $attachment) {
if ($attachment->thread_id && $attachment->thread_id != $thread_id) {
$attachments_to_remove = array_diff($attachments_to_remove, [$attachment->id]);
}
}
Attachment::deleteByIds($attachments_to_remove);
}
}
return [
@@ -3179,7 +3353,7 @@ class ConversationsController extends Controller
/**
* Undo reply.
*/
public function undoReply(Request $request, $thread_id)
public function undoReply(Request $request, $thread_id, $token)
{
$thread = Thread::findOrFail($thread_id);
@@ -3187,13 +3361,23 @@ class ConversationsController extends Controller
abort(404);
}
if ($thread->created_by_user_id != \Auth::id()) {
\Session::flash('flash_error_floating', __('Sending can not be undone'));
return redirect()->away($conversation->url($conversation->folder_id));
}
$conversation = $thread->conversation;
$this->authorize('view', $conversation);
if (csrf_token() != $token) {
//return throw new \Illuminate\Session\TokenMismatchException;
\Session::flash('flash_error_floating', __('Sending can not be undone'));
return redirect()->away($conversation->url($conversation->folder_id));
}
// 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));
}
@@ -3225,8 +3409,8 @@ class ConversationsController extends Controller
// Restore conversation data from penultimate thread
if ($last_thread) {
$conversation->setCc($last_thread->cc);
$conversation->setBcc($last_thread->bcc);
$conversation->setCc($last_thread->getCcArray());
$conversation->setBcc($last_thread->getBccArray());
$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');
@@ -3263,7 +3447,7 @@ class ConversationsController extends Controller
/**
* Find or create customer when creating a Phone conversation.
*/
public function processPhoneCustomer($request)
public function processPhoneCustomer($request, $user)
{
$customer_data = [];
$customer_email = '';
@@ -3290,6 +3474,14 @@ class ConversationsController extends Controller
if (!$request->customer_id && is_numeric($request_name)) {
// Try to find customer by ID.
$customer = Customer::find($request_name);
if ($customer) {
if (!$user->can('view', $customer)) {
return [
'status' => 'error',
'msg' => __('Inaccessible customer'),
];
}
}
}
if (!$customer && $request->to_email) {
@@ -3317,6 +3509,12 @@ class ConversationsController extends Controller
if ($request->customer_id) {
$customer = Customer::find($request->customer_id);
if ($customer) {
if (!$user->can('view', $customer)) {
return [
'status' => 'error',
'msg' => __('Inaccessible customer'),
];
}
// Add email to customer.
$customer->addEmail($customer_email, true);
} else {
@@ -3329,6 +3527,12 @@ class ConversationsController extends Controller
if ($request->customer_id) {
$customer = Customer::find($request->customer_id);
if ($customer) {
if (!$user->can('view', $customer)) {
return [
'status' => 'error',
'msg' => __('Inaccessible customer'),
];
}
$customer->setData($customer_data, false, true);
}
}

View File

@@ -113,6 +113,17 @@ class CustomersController extends Controller
foreach ($new_emails as $new_email) {
$email = Email::where('email', $new_email)->first();
if ($email && $email->customer) {
// Prevent pulling in an out-of-scope customer by attaching one
// of that customer's emails to the current profile.
if ($email->customer_id !== $customer->id) {
if (!$this->checkLimitVisibility($email->customer, true)) {
$validator->errors()->add('email', __('The specified email belongs to a customer from an inaccessible mailbox.'));
return redirect()->route('customers.update', ['id' => $id])
->withErrors($validator)
->withInput();
}
}
// 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) {
@@ -131,7 +142,7 @@ class CustomersController extends Controller
'tag_email_begin' => '<strong>',
'tag_email_end' => '</strong>',
'customer' => htmlspecialchars($email->customer->getFullName()),
'a_begin' => '<strong><a href="'.$email->customer->url().'" target="_blank">',
'a_begin' => '<strong><a href="'.$email->customer->url().'">',
'a_end' => '</a></strong>',
]).' ';
@@ -206,53 +217,19 @@ class CustomersController extends Controller
return redirect()->route('customers.update', ['id' => $id]);
}
public function checkLimitVisibility($customer)
public function checkLimitVisibility($customer, $return_result = false)
{
$user = auth()->user();
$limited_visibility = config('app.limit_user_customer_visibility') && !$user->isAdmin();
if ($limited_visibility) {
$mailbox_ids = $user->mailboxesIdsCanView();
$accesible = Conversation::where('customer_id', $customer->id)
->whereIn('conversations.mailbox_id', $mailbox_ids)
->exists();
if (!$accesible) {
if (!$user->can('view', $customer)) {
if (!$return_result) {
\Helper::denyAccess();
} else {
return false;
}
}
}
/**
* 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]);
return true;
}
/**
@@ -264,6 +241,8 @@ class CustomersController extends Controller
{
$customer = Customer::findOrFail($id);
$this->checkLimitVisibility($customer);
$query = $customer->conversations()
->where('customer_id', $customer->id)
->whereIn('mailbox_id', auth()->user()->mailboxesIdsCanView())
@@ -305,7 +284,12 @@ class CustomersController extends Controller
$select_list = ['customers.id', 'first_name', 'last_name'];
if ($join_emails) {
$select_list[] = 'emails.email';
if ($limited_visibility) {
// https://github.com/freescout-help-desk/freescout/issues/5032
$select_list[] = \DB::raw('MAX('.\DB::getTablePrefix().'emails.email)');
} else {
$select_list[] = 'emails.email';
}
}
if ($request->show_fields == 'phone') {
$select_list[] = 'phones';
@@ -439,17 +423,29 @@ class CustomersController extends Controller
$validator = Validator::make($request->all(), $validator_config);
if ($validator->fails()) {
foreach ($validator->errors()->getMessages()as $errors) {
foreach ($validator->errors()->getMessages() as $errors) {
foreach ($errors as $field => $message) {
$response['msg'] .= $message.' ';
}
}
}
if (!$response['msg'] && $limited_visibility) {
$existing_email = Email::where('email', $request->email)->first();
if ($existing_email
&& $existing_email->customer
&& !$user->can('view', $existing_email->customer)
) {
$response['msg'] .= __('The specified email belongs to a customer from an inaccessible mailbox.');
}
}
if (!$response['msg']) {
$customer = Customer::create($request->email, $request->all());
if ($customer) {
session()->put('user_created_customer', $customer->id);
$response['email'] = $request->email;
$response['status'] = 'success';
}
@@ -484,6 +480,8 @@ class CustomersController extends Controller
{
$customer = Customer::findOrFail($id);
$this->checkLimitVisibility($customer);
// $customers = Customer::where('id', '!=', $id)
// ->orderBy('first_name')
// ->orderBy('last_name')
@@ -498,12 +496,17 @@ class CustomersController extends Controller
public function mergeSave(Request $request, $id)
{
$request->validate([
'customer2_id' => 'required|exists:customers,id',
'customer2_id' => 'required|exists:customers,id|not_in:'.$id,
//'keep_attributes' => 'array'
], [], [
'customer2_id' => __('Merge With'),
]);
$customer = Customer::findOrFail($id);
$customer2 = Customer::find($request->customer2_id);
$customer2 = Customer::findOrFail($request->customer2_id);
$this->checkLimitVisibility($customer);
$this->checkLimitVisibility($customer2);
// Ensure customers are different
if ($id === $customer2->id) {

View File

@@ -36,7 +36,7 @@ class MailboxesController extends Controller
if (!\Eventy::filter('user.can_view_mailbox_menu', false, $user)) {
foreach ($mailboxes as $i => $mailbox) {
if (!$user->canManageMailbox($mailbox->id)) {
if (!$user->canManageMailbox($mailbox)) {
$mailboxes->forget($i);
}
}
@@ -178,7 +178,7 @@ class MailboxesController extends Controller
$allowed_fields = [];
if ($user->can('updateSettings', $mailbox)) {
if ($can_update_settings) {
// Checkboxes
$request->merge([
@@ -186,10 +186,10 @@ class MailboxesController extends Controller
]);
// if not admin, the text only fields don't pass so spike them into the request.
if (!auth()->user()->isAdmin()) {
if (!$user->isAdmin()) {
$request->merge([
'name' => $mailbox->name,
'email' => $mailbox->email
'email' => $mailbox->email,
]);
}
@@ -257,14 +257,27 @@ class MailboxesController extends Controller
$mailbox->fill($fields);
// Chat: Start a new conversation when receiving a reply to the closed / deleted Chat conversation.
if (!empty($request->chat_start_new)) {
$mailbox->setMetaParam('chat_start_new', true);
} else {
$mailbox->removeMetaParam('chat_start_new');
// Save state.
if ($user->isAdmin()) {
if ($request->filled('state')) {
$mailbox->state = Mailbox::STATE_ARCHIVED;
} else {
$mailbox->state = Mailbox::STATE_ACTIVE;
}
}
$mailbox->signature = \Helper::stripDangerousTags($mailbox->signature);
// Chat: Start a new conversation when receiving a reply to the closed / deleted Chat conversation.
if ($can_update_settings) {
if (!empty($request->chat_start_new)) {
$mailbox->setMetaParam('chat_start_new', true);
} else {
$mailbox->removeMetaParam('chat_start_new');
}
}
// Purify signature HTML to avoid sending unsafe HTML to customers by email.
//$mailbox->signature = \Helper::stripDangerousTags($mailbox->signature);
$mailbox->signature = Mailbox::sanitizeSignature($mailbox->signature);
$mailbox->save();
@@ -402,9 +415,9 @@ class MailboxesController extends Controller
if ($request->out_method == Mailbox::OUT_METHOD_SMTP) {
$validator = Validator::make($request->all(), [
'out_server' => 'required|string|max:255',
'out_server' => 'required|string|max:255|safehost',
'out_port' => 'required|integer',
'out_username' => 'nullable|string|max:100',
'out_username' => 'nullable|string|max:255',
'out_password' => 'nullable|string|max:255',
'out_encryption' => 'required|integer',
]);
@@ -422,6 +435,18 @@ class MailboxesController extends Controller
} else {
$params = $request->all();
}
// Leave only allowed fields.
$params = \Helper::filterArrayByKeys($params, [
'out_method',
'out_server',
'out_port',
'out_username',
'out_password',
'out_encryption',
'send_test_to',
]);
$mailbox->fill($params);
$mailbox->save();
@@ -453,13 +478,22 @@ class MailboxesController extends Controller
];
$validator = Validator::make($fields, [
'in_server' => 'required',
//'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);
$response = view('mailboxes/connection_incoming', ['mailbox' => $mailbox, 'flashes' => $this->mailboxActiveWarning($mailbox)]);
if (empty($request->session()->get('errors'))) {
if (empty($mailbox->in_server)) {
$validator->errors()->add('in_server', 'dummy');
}
$response->withErrors($validator);
}
return $response;
}
/**
@@ -470,18 +504,18 @@ class MailboxesController extends Controller
$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',
// ]);
$validator = Validator::make($request->all(), [
'in_server' => 'required|string|max:255|safehost',
'in_port' => 'required|integer',
'in_username' => 'required|string|max:100',
'in_password' => 'required|string|max:255',
]);
// if ($validator->fails()) {
// return redirect()->route('mailboxes.connection.incoming', ['id' => $id])
// ->withErrors($validator)
// ->withInput();
// }
if ($validator->fails()) {
return redirect()->route('mailboxes.connection.incoming', ['id' => $id])
->withErrors($validator)
->withInput();
}
// Checkboxes
$request->merge([
@@ -497,6 +531,19 @@ class MailboxesController extends Controller
\Eventy::action('mailbox.incoming_settings_before_save', $mailbox, $request);
// Leave only allowed fields.
$params = \Helper::filterArrayByKeys($params, [
'in_protocol',
'in_server',
'in_port',
'in_username',
'in_password',
'in_encryption',
'in_imap_folders',
'in_validate_cert',
'imap_sent_folder',
]);
$mailbox->fill($params);
// Save IMAP Folders.
@@ -564,14 +611,14 @@ class MailboxesController extends Controller
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').' » <a href="'.route('mailboxes.connection', ['id' => $mailbox->id]).'">'.__('Sending Emails').'</a>)',
'text' => '<i class="glyphicon glyphicon-flash"></i> '.__('Sending emails need to be configured for the mailbox in order to send emails to customers and support agents').' ('.__('Connection Settings').' » <a href="'.route('mailboxes.connection', ['id' => $mailbox->id]).'">'.__('Sending Emails').'</a>)',
'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').' » <a href="'.route('mailboxes.connection.incoming', ['id' => $mailbox->id]).'">'.__('Receiving Emails').'</a>)',
'text' => '<i class="glyphicon glyphicon-flash"></i> '.__('Receiving emails need to be configured for the mailbox in order to fetch emails from your support email address').' ('.__('Connection Settings').' » <a href="'.route('mailboxes.connection.incoming', ['id' => $mailbox->id]).'">'.__('Fetching Emails').'</a>)',
'unescaped' => true,
];
}
@@ -637,6 +684,8 @@ class MailboxesController extends Controller
];
$mailbox->fill($data);
$mailbox->auto_reply_message = \Helper::purifyHtml($mailbox->auto_reply_message);
// To be 100% sure.
$mailbox->auto_reply_message = \Helper::stripDangerousTags($mailbox->auto_reply_message);
$mailbox->save();
@@ -692,15 +741,23 @@ class MailboxesController extends Controller
// 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' => '<strong>'.$mailbox->out_server.'</strong>', 'port' => '<strong>'.$mailbox->out_port.'</strong>']);
$test_result = false;
try {
$test_result = \Helper::checkPort($mailbox->out_server, $mailbox->out_port);
} catch (\Exception $e) {
if ($e->getCode() == \Helper::EXCEPTION_UNSAFE_URL) {
$response['msg'] = $e->getMessage();
}
}
if (empty($response['msg']) && !$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 = [
'status' => 'error'
'status' => 'error',
];
try {
@@ -743,9 +800,16 @@ class MailboxesController extends Controller
// 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' => '<strong>'.$mailbox->in_server.'</strong>', 'port' => '<strong>'.$mailbox->in_port.'</strong>']);
$test_result = false;
try {
$test_result = \Helper::checkPort($mailbox->in_server, $mailbox->in_port);
} catch (\Exception $e) {
if ($e->getCode() == \Helper::EXCEPTION_UNSAFE_URL) {
$response['msg'] = $e->getMessage();
}
}
if (empty($response['msg']) && !$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]);
}
}
@@ -845,20 +909,7 @@ class MailboxesController extends Controller
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();
$mailbox->deleteMailbox();
\Session::flash('flash_success_floating', __('Mailbox deleted'));
@@ -908,7 +959,8 @@ class MailboxesController extends Controller
}
// Recursively interate over folders.
public function interateFolders($response, $imap_folders, $subfolder = false) {
public function interateFolders($response, $imap_folders, $subfolder = false)
{
foreach ($imap_folders as $imap_folder) {
if (!empty($imap_folder->name) && !$subfolder) {
$response['folders'][] = $imap_folder->name;
@@ -967,10 +1019,10 @@ class MailboxesController extends Controller
return __('Mailbox not found').': '.$mailbox_id;
}
if ($in_out == 'in') {
$username = $mailbox->in_username;
$username = $mailbox->getInOauthClientId();
$password = $mailbox->in_password;
} else {
$username = $mailbox->out_username;
$username = $mailbox->getOutOauthClientId();
$password = $mailbox->out_password;
}
if (empty($username)) {
@@ -993,7 +1045,7 @@ class MailboxesController extends Controller
'in_out' => $in_out,
'state' => crc32($username.$password),
];
$url = \MailHelper::oauthGetAuthorizationUrl(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
$url = \MailHelper::oauthGetAuthorizationUrl($provider, [
'state' => json_encode($state),
'client_id' => $username,
]);
@@ -1018,7 +1070,7 @@ class MailboxesController extends Controller
} else {
// state is set.
// Try to get an access token (using the authorization code grant)
$token_data = \MailHelper::oauthGetAccessToken(\MailHelper::OAUTH_PROVIDER_MICROSOFT, [
$token_data = \MailHelper::oauthGetAccessToken($provider, [
'client_id' => $username,
'client_secret' => $password,
'code' => $request->code,
@@ -1028,7 +1080,7 @@ class MailboxesController extends Controller
// Set username and password for the oppozite in_out.
if ($in_out == 'in') {
if (empty($mailbox->out_server)
|| (trim($mailbox->out_server) == \MailHelper::OAUTH_MICROSOFT_SMTP
|| ($mailbox->isOutServerOauth()
&& (!$mailbox->out_username || $mailbox->out_username == $username))
) {
$mailbox->out_username = $username;
@@ -1062,6 +1114,10 @@ class MailboxesController extends Controller
$mailbox = Mailbox::findOrFail($mailbox_id);
$this->authorize('admin', $mailbox);
if (!\Helper::hashEquals(csrf_token(), $request->token)) {
throw new \Illuminate\Session\TokenMismatchException;
}
// oAuth Disconnect.
$mailbox->removeMetaParam('oauth', true);
@@ -1071,6 +1127,6 @@ class MailboxesController extends Controller
$route = 'mailboxes.connection';
}
return \MailHelper::oauthDisconnect($provider, route($route, ['id' => $mailbox_id]));
return \MailHelper::oauthDisconnect($provider, route($route, ['id' => $mailbox_id]), $mailbox);
}
}

View File

@@ -96,17 +96,24 @@ class ModulesController extends Controller
if (is_array($modules_directory)) {
foreach ($modules_directory as $i_dir => $dir_module) {
$modules_directory[$i_dir] = \App\Module::formatModuleData($dir_module);
$dir_module = \App\Module::formatModuleData($dir_module);
$modules_directory[$i_dir] = $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'];
$installed_modules[$i_installed] = \App\Module::formatModuleData($installed_modules[$i_installed]);
// Remove installed modules from modules directory.
unset($modules_directory[$i_dir]);
@@ -120,11 +127,6 @@ class ModulesController extends Controller
}
}
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']);
}
@@ -135,6 +137,9 @@ class ModulesController extends Controller
if (\App\Module::isThirdParty($dir_module)) {
$third_party_modules[] = $modules_directory[$i_dir];
unset($modules_directory[$i_dir]);
} elseif (empty($dir_module['authorUrl']) || !\App\Module::isOfficial($dir_module['authorUrl'])) {
unset($modules_directory[$i_dir]);
continue;
}
}
} else {
@@ -198,6 +203,10 @@ class ModulesController extends Controller
// Sort all modules.
asort($all_modules);
usort($third_party_modules, function ($a, $b) {
return strcasecmp($a['name'], $b['name']);
});
return view('modules/modules', [
'installed_modules' => $installed_modules,
'modules_directory' => $modules_directory,
@@ -221,10 +230,11 @@ class ModulesController extends Controller
switch ($request->action) {
// Install module or activate license.
case 'install':
case 'activate_license':
$license = $request->license;
$alias = $request->alias;
$alias = preg_replace("#[^a-zA-Z0-9_\-]#", '', $request->alias);
if (!$license) {
$response['msg'] = __('Empty license key');
@@ -317,8 +327,11 @@ class ModulesController extends Controller
}
}
}
break;
// If everything is fine try to activate the Module.
if (!empty($response['msg'])) {
break;
}
// Continue to activation.
case 'activate':
$alias = $request->alias;
$module = \Module::findByAlias($alias);
@@ -331,7 +344,7 @@ class ModulesController extends Controller
if (!$response['msg']) {
if (!empty($module->get('authorUrl')) && $module->isOfficial()) {
$params = [
'license' => $module->getLicense(),
'license' => $request->license ?: $module->getLicense(),
'module_alias' => $alias,
'url' => \App\Module::getAppUrl(),
];
@@ -355,6 +368,7 @@ class ModulesController extends Controller
break;
case 'inactive':
$response['msg'] = __('License key has not been activated yet');
break;
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;
@@ -376,6 +390,53 @@ class ModulesController extends Controller
}
}
}
} else {
// Check folder names for custom modules o avoid errors after activation:
// Class "Modules\CustomModule\Providers\CustomModuleServiceProvider" not found.
try {
$old_path = '';
$correct_path = '';
foreach (\Module::getScanPaths() as $key => $path) {
$manifests = \Module::getFiles()->glob("{$path}/module.json");
is_array($manifests) || $manifests = [];
// Determine correct module folder name from providers in module.json.
// Each module must have at least on "provider" specified.
foreach ($manifests as $manifest) {
$manifest_json = \Nwidart\Modules\Json::make($manifest);
if ($manifest_json->get('alias') != $alias) {
continue;
}
$providers = $manifest_json->get('providers');
$correct_folder_name = $providers[0] ?? '';
$correct_folder_name = preg_replace('#^Modules\\\\([^\\\\]+)\\\\.*#', '$1', $correct_folder_name);
if (!$correct_folder_name) {
break;
}
// Rename module's folder into correct name.
$old_path = str_replace('/module.json', '', $manifest);
$correct_path = \Module::getPath().'/'.$correct_folder_name;
if (\File::exists($old_path) && !\File::exists($correct_path)) {
\File::move($old_path, $correct_path);
// Re-scan and re-cache modules.
\Module::scan();
}
break;
}
}
} catch (\Exception $e) {
if ($old_path && $correct_path) {
$response['msg'] = __('Rename ":old_path" into ":new_path"', [
'old_path' => str_replace(\Module::getPath(), '/Modules', $old_path),
'new_path' => str_replace(\Module::getPath(), '/Modules', $correct_path),
]);
} else {
\Helper::logException($e, '[Modules] Error occured checking module folder name on activation');
}
}
}
}
@@ -522,6 +583,8 @@ class ModulesController extends Controller
$response['status'] = 'success';
} elseif (!empty($result['error'])) {
$response['msg'] = \App\Module::getErrorMessage($result['error'], $result);
} elseif (!empty($result['status']) && $result['status'] == 'error') {
$response['msg'] = __('License key does not exist');
} else {
$response['msg'] = __('Error occurred. Please try again later.');
}

View File

@@ -27,34 +27,56 @@ class OpenController extends Controller
*
* @return \Illuminate\Http\Response
*/
public function userSetup($hash)
public function userSetup($hash, $invite_sent_at)
{
if (auth()->user()) {
return redirect()->route('dashboard');
}
$user = User::where('invite_hash', $hash)->first();
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-jqj5-r72v-v29g
$hash = trim($hash);
$user = null;
if (strlen($hash) == User::INVITE_HASH_LENGTH) {
$user = User::where('invite_hash', $hash)->first();
}
if ($user && $user->locale) {
\Helper::setLocale($user->locale);
}
if ($user && !$this->isInviteExpirationValid($invite_sent_at, $user)) {
$user = null;
}
return view('open/user_setup', ['user' => $user]);
}
/**
* Save user from invitation.
*/
public function userSetupSave($hash, Request $request)
public function userSetupSave($hash, $invite_sent_at, Request $request)
{
if (auth()->user()) {
return redirect()->route('dashboard');
}
$user = User::where('invite_hash', $hash)->first();
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-jqj5-r72v-v29g
$hash = trim($hash);
$user = null;
if (strlen($hash) == User::INVITE_HASH_LENGTH) {
$user = User::where('invite_hash', $hash)->first();
}
if (!$user) {
abort(404);
}
if (!$this->isInviteExpirationValid($invite_sent_at, $user)) {
\Helper::denyAccess();
}
$validator = Validator::make($request->all(), [
'email' => 'required|string|email|max:100|unique:users,email,'.$user->id,
'password' => 'required|string|min:8|confirmed',
@@ -82,7 +104,7 @@ class OpenController extends Controller
});
if ($validator->fails()) {
return redirect()->route('user_setup', ['hash' => $hash])
return redirect()->route('user_setup', ['hash' => $hash, 'invite_sent_at' => $invite_sent_at])
->withErrors($validator)
->withInput();
}
@@ -114,21 +136,48 @@ class OpenController extends Controller
return redirect()->route('dashboard');
}
public function isInviteExpirationValid($invite_sent_at_encrypted, $user)
{
$invite_sent_at = \Helper::decrypt($invite_sent_at_encrypted, $user->password);
if ($invite_sent_at == $invite_sent_at_encrypted) {
return false;
}
if (!$invite_sent_at
|| !is_numeric($invite_sent_at)
|| (int)$invite_sent_at < time() - User::INVITE_TTL_DAYS*86400
) {
return false;
}
return true;
}
/*
* Set a thread as read by customer
*/
public function setThreadAsRead($conversation_id, $thread_id)
public function setThreadAsRead($conversation_id, $thread_id, $hash)
{
$mark_as_read = true;
$conversation = Conversation::findOrFail($conversation_id);
$thread = Thread::findOrFail($thread_id);
if ((int)$thread->conversation_id !== (int)$conversation_id) {
$mark_as_read = false;
} elseif (!\Helper::hashEquals($hash, Thread::getOpenTrackingHash($thread, $conversation, $conversation->mailbox))) {
$mark_as_read = false;
}
// We only track the first opening
if (empty($thread->opened_at)) {
if ($mark_as_read && empty($thread->opened_at)) {
$thread->opened_at = date('Y-m-d H:i:s');
$thread->save();
\Eventy::action('thread.opened', $thread, $conversation);
}
// We return always the same result for security purposes.
// 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);
@@ -167,8 +216,15 @@ class OpenController extends Controller
->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) {
// Check attachment name.
if (mb_strtolower(trim($attachment->file_name)) != mb_strtolower(trim($file_name))) {
return \Helper::denyAccess();
}
// Links to atachments without token have been disabled.
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-wg74-ww4w-2qpc
//if ($token != $attachment->getToken() && $attachment->token_type != Attachment::TOKEN_TYPE_LEGACY) {
if (!\Helper::hashEquals($token, $attachment->getToken())) {
return \Helper::denyAccess();
}
@@ -184,36 +240,93 @@ class OpenController extends Controller
$allowed_mime_type = false;
foreach (config('app.viewable_mime_types') as $mime_type) {
if (preg_match('#'.$mime_type.'#', $attachment->mime_type)) {
if (preg_match('#^'.$mime_type.'$#', $attachment->mime_type)) {
$allowed_mime_type = true;
break;
}
}
if ($allowed_mime_type) {
foreach (config('app.non_viewable_mime_types') as $mime_type) {
if (preg_match('#^'.$mime_type.'$#', $attachment->mime_type)) {
$allowed_mime_type = false;
break;
}
}
}
if (!$allowed_mime_type) {
$view_attachment = false;
}
}
// CSP header for extra security.
$csp_header_value = "script-src 'none'; frame-src 'none'; object-src 'none'; font-src 'none'; connect-src 'none'; media-src 'self'; form-action 'none'; base-uri 'none'; sandbox";
// https://github.com/freescout-help-desk/freescout/issues/5281
//
// The bare sandbox directive gives the attachment document an opaque
// ("null") origin. The media element the browser's built-in player creates
// then requests the file cross-origin and is blocked by CORS, so the player
// appears but never plays. allow-same-origin keeps the real origin.
// nosniff replaces the protection sandbox was providing here: the browser
// is not allowed to reinterpret an audio/* response as HTML.
// https://github.com/freescout-help-desk/freescout/pull/5597
if (preg_match('#^audio/.*$#', $attachment->mime_type)) {
//$csp_header_value .= ' allow-scripts';
$csp_header_value .= ' allow-same-origin';
}
// Send file.
if (config('app.download_attachments_via') == 'apache') {
// Send using Apache mod_xsendfile.
$response = response(null)
->header('Content-Type' , $attachment->mime_type)
->header('Content-Type', $attachment->mime_type)
// Laravel adds 'nosniff' by itself.
//->header('X-Content-Type-Options', 'nosniff')
->header('X-Sendfile', $attachment->getLocalFilePath());
if (!$view_attachment) {
$response->header('Content-Disposition', 'attachment; filename="'.$attachment->file_name.'"');
} else {
$response->header('Content-Security-Policy', $csp_header_value);
}
} elseif (config('app.download_attachments_via') == 'nginx') {
// Send using Nginx.
$response = response(null)
->header('Content-Type' , $attachment->mime_type)
->header('Content-Type', $attachment->mime_type)
// Laravel adds 'nosniff' by itself.
//->header('X-Content-Type-Options', 'nosniff')
->header('X-Accel-Redirect', $attachment->getLocalFilePath(false));
if (!$view_attachment) {
$response->header('Content-Disposition', 'attachment; filename="'.$attachment->file_name.'"');
} else {
$response->header('Content-Security-Policy', $csp_header_value);
}
} else {
$response = $attachment->download($view_attachment);
// Send via PHP.
// In this case whether the file is viewed or downloaded is controlled
// via the web server config or /storage/app/public/.htaccess.
$headers = [
'Content-Type' => $attachment->mime_type,
// Laravel adds 'nosniff' by itself.
//'X-Content-Type-Options' => 'nosniff',
];
if ($view_attachment) {
$headers['Content-Security-Policy'] = $csp_header_value;
}
// Storage::download() returns a StreamedResponse, which does not
// implement HTTP Range requests: no Accept-Ranges, no 206 responses.
// Browsers disable seeking without it, so audio and video attachments
// can not be rewound or fast forwarded. BinaryFileResponse handles
// Range requests, so use it whenever the file is shown in the browser.
if ($view_attachment && \Helper::isLocalStorage() && $attachment->fileExists()) {
$response = response()->file(
$attachment->getLocalFilePath(),
$headers
);
} else {
$response = $attachment->download($view_attachment, $headers);
}
}
return $response;

View File

@@ -29,11 +29,8 @@ class SecureController extends Controller
public function dashboard()
{
$user = auth()->user();
if (!$user->isAdmin()) {
$mailboxes = $user->mailboxesCanView();
} else {
$mailboxes = $user->mailboxesCanViewWithSettings();
}
$mailboxes = $user->mailboxesCanViewWithSettings();
// Sort by name.
$mailboxes = \Eventy::filter('dashboard.mailboxes', $mailboxes->sortBy('name'));
@@ -200,7 +197,7 @@ class SecureController extends Controller
/**
* Upload files and images.
*/
public function upload(Request $request, $allowed_exts = [])
public function upload(Request $request)
{
// 'jpg','gif','png'
$response = [
@@ -220,14 +217,24 @@ class SecureController extends Controller
if (!$response['msg']) {
$upload = Helper::uploadFile($request->file, $allowed_exts);
$filename = basename($upload);
$uploaded_file_path = '';
try {
$uploaded_file_path = Helper::uploadFile($request->file);
} catch (\Exception $e) {
if ($e->getCode() == \Helper::EXCEPTION_NOT_ALLOWED_FILE_EXTENSION) {
$response['msg'] = $e->getMessage();
}
}
if ($upload) {
$response['status'] = 'success';
$response['url'] = Helper::uploadedFileUrl($filename);
} else {
$response['msg'] = __('Error occurred uploading file');
if (!$response['msg']) {
$filename = basename($uploaded_file_path);
if ($uploaded_file_path) {
$response['status'] = 'success';
$response['url'] = Helper::uploadedFileUrl($filename);
} else {
$response['msg'] = __('Error occurred uploading file');
}
}
}

View File

@@ -94,6 +94,7 @@ class SettingsController extends Controller
],
],
'validator_rules' => [
'settings.mail_host' => 'safehost',
'settings.mail_from' => 'required|email',
],
'settings' => [
@@ -135,6 +136,9 @@ class SettingsController extends Controller
'env' => 'APP_USER_PERMISSIONS',
'env_encode' => true,
],
'limit_user_customer_visibility' => [
'env' => 'APP_LIMIT_USER_CUSTOMER_VISIBILITY',
],
],
];
break;
@@ -200,6 +204,7 @@ class SettingsController extends Controller
'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(),
'limit_user_customer_visibility' => (int)config('app.limit_user_customer_visibility'),
'email_branding' => Option::get('email_branding'),
'open_tracking' => Option::get('open_tracking'),
'email_conv_history' => config('app.email_conv_history'),
@@ -232,11 +237,13 @@ class SettingsController extends Controller
'alert_logs',
'alert_logs_names',
'alert_logs_period',
'alert_logs_fetch_min_occurrences',
'subscription_defaults',
], [
'alert_logs_names' => [],
'alert_logs' => config('app.alert_logs'),
'alert_logs_period' => config('app.alert_logs_period'),
'alert_logs_names' => [],
'alert_logs' => config('app.alert_logs'),
'alert_logs_period' => config('app.alert_logs_period'),
'alert_logs_fetch_min_occurrences' => \App\Console\Commands\LogsMonitor::FETCH_ERRORS_MIN_OCCURRENCES_DEFAULT,
]);
break;
default:
@@ -313,7 +320,7 @@ class SettingsController extends Controller
$env_value = json_encode($env_value);
}
if (!empty($settings_params[$option_name]['encrypt'])) {
if ($env_value !== '' && !empty($settings_params[$option_name]['encrypt'])) {
$env_value = encrypt($env_value);
}
@@ -395,7 +402,7 @@ class SettingsController extends Controller
if (!$response['msg']) {
$test_result = [
'status' => 'error'
'status' => 'error',
];
try {

View File

@@ -54,11 +54,15 @@ class SystemController extends Controller
$non_writable_cache_file = '';
if (function_exists('shell_exec')) {
$non_writable_cache_file = \Helper::shellExec('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 = '';
if ($non_writable_cache_file === false) {
$non_writable_cache_file = 'Could not execute command via shell_exec()';
} else {
$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 = '';
}
}
}
@@ -87,7 +91,7 @@ class SystemController extends Controller
// Commands
$commands_list = [
'freescout:fetch-emails' => 'freescout:fetch-emails',
\Helper::getWorkerIdentifier() => 'queue:work'
\Helper::getWorkerIdentifier() => 'queue:work',
];
foreach ($commands_list as $command_identifier => $command_name) {
$status_texts = [];
@@ -97,19 +101,29 @@ class SystemController extends Controller
$running_commands = 0;
try {
$processes = preg_split("/[\r\n]/", \Helper::shellExec("ps auxww | 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];
$ps_output = \Helper::shellExec("ps auxww | grep '{$command_identifier}'");
if ($ps_output === false) {
$commands[] = [
'name' => $command_name,
'status' => 'error',
'status_text' => __h('Could not execute command via shell_exec()'),
];
} else {
$processes = preg_split("/[\r\n]/", $ps_output);
$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) {
@@ -119,7 +133,7 @@ class SystemController extends Controller
$commands[] = [
'name' => $command_name,
'status' => 'success',
'status_text' => __('Running'),
'status_text' => __h('Running'),
];
continue;
} elseif ($running_commands > 1) {
@@ -129,14 +143,14 @@ class SystemController extends Controller
$commands[] = [
'name' => $command_name,
'status' => 'error',
'status_text' => __(':number commands were running at the same time. Commands have been restarted', ['number' => $running_commands]),
'status_text' => __h(':number commands were running at the same time. Commands have been restarted', ['number' => htmlspecialchars($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),
'status_text' => __h(':number commands are running at the same time. Please stop extra commands by executing the following console command:', ['number' => htmlspecialchars($running_commands)]).' kill '.implode(' | kill ', $pids),
];
}
continue;
@@ -151,7 +165,7 @@ class SystemController extends Controller
$date = Carbon::createFromTimestamp($last_run);
$date_text = User::dateFormat($date);
}
$status_texts[] = __('Last run:').' '.$date_text;
$status_texts[] = __h('Last run:').' '.htmlspecialchars($date_text);
$date_text = '?';
$last_successful_run = Option::get($option_name.'_last_successful_run');
@@ -159,7 +173,7 @@ class SystemController extends Controller
$date_ = Carbon::createFromTimestamp($last_successful_run);
$date_text = User::dateFormat($date);
}
$status_texts[] = __('Last successful run:').' '.$date_text;
$status_texts[] = __h('Last successful run:').' '.htmlspecialchars($date_text);
$status = 'error';
if ($last_successful_run && $last_run && (int) $last_successful_run >= (int) $last_run) {
@@ -169,7 +183,7 @@ class SystemController extends Controller
// 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 href="'.route('system.tools').'" target="_blank">', '%a_end%' => '</a>']);
$status_texts[] = __h('Try to :%a_start%clear cache:%a_end% to force command to start.', ['%a_start%' => '<a href="'.route('system.tools').'" target="_blank">', '%a_end%' => '</a>']);
// This sometimes makes Status page open as non logged in user.
//\Artisan::call('freescout:clear-cache', ['--doNotGenerateVars' => true]);
}
@@ -378,18 +392,24 @@ class SystemController extends Controller
*/
public function cron(Request $request)
{
if (empty($request->hash) || $request->hash != \Helper::getWebCronHash()) {
if (empty($request->hash) || !\Helper::hashEquals($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');
preg_match_all("#'artisan'\s+([^\s>]+)#", $output ?? '', $m);
$commands = $m[1] ?? [];
$result = count($commands)." commands executed:\r\n".(count($commands) ? '- ' : '').implode("\r\n- ", $commands);
return response($result, 200)->header('Content-Type', 'text/plain');
}
/**
* Ajax HTML.
* Content Security Policy header is sent via ContentSecurityPolicy middleware.
*/
public function ajaxHtml(Request $request)
{
@@ -404,7 +424,7 @@ class SystemController extends Controller
$payload = json_decode($job->payload, true);
if (!empty($payload['data']['command'])) {
$html .= '<pre>'.\Helper::stripDangerousTags(print_r(unserialize($payload['data']['command']), 1)).'</pre>';
$html .= '<pre>'.\Helper::stripDangerousTags(print_r(unserialize($payload['data']['command'], ['allowed_classes' => false]), 1)).'</pre>';
}
$html .= '<pre>'.\Helper::stripDangerousTags($job->exception).'</pre>';

View File

@@ -69,9 +69,13 @@ class TranslateController extends BaseController
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');
$archive_path = \Helper::createZipArchive(base_path().DIRECTORY_SEPARATOR.'resources/lang', 'lang.zip', 'lang');
$hash = substr(\Helper::hmacHash($archive_path), 0, 10);
$file_name = 'lang_'.$hash.'.zip';
$public_path = storage_path('app/public/'.$file_name);
\File::copy($archive_path, $public_path);

View File

@@ -32,7 +32,14 @@ class UsersController extends Controller
{
$this->authorize('create', 'App\User');
$users = User::nonDeleted()->get();
$users_query = User::nonDeleted();
// Exclude admins from list for non-admins.
if (!auth()->user()->isAdmin()) {
$users_query->where('role', User::ROLE_USER);
}
$users = $users_query->get();
$users = User::sortUsers($users);
return view('users/users', ['users' => $users]);
@@ -44,7 +51,7 @@ class UsersController extends Controller
public function create()
{
$this->authorize('create', 'App\User');
$mailboxes = Mailbox::all();
$mailboxes = auth()->user()->mailboxesCanView();
return view('users/create', ['mailboxes' => $mailboxes]);
}
@@ -62,7 +69,7 @@ class UsersController extends Controller
$rules = [
'first_name' => 'required|string|max:20',
'last_name' => 'required|string|max:30',
'last_name' => 'max:30',
'email' => 'required|string|email|max:100|unique:users',
//'role' => ['required', Rule::in(array_keys(User::$roles))],
];
@@ -85,32 +92,44 @@ class UsersController extends Controller
->withInput();
}
$password = '';
if (empty($request->send_invite)) {
// Set password from request.
$password = $request->password;
} else {
// Set some random password before sending invite.
$password = User::generateRandomPassword();
}
$data = [
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'last_name' => $request->last_name ?? '',
'email' => $request->email,
'password' => $password,
];
$user = new User();
$user->fill($data);
$user = User::create($data);
if (!$auth_user->can('changeRole', $user)) {
$user->role = User::ROLE_USER;
} elseif (array_key_exists($request->role, User::$roles)) {
$user->role = $request->role;
}
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 ?: []);
$mailbox_ids = $request->mailboxes ?: [];
if (!$auth_user->isAdmin()) {
// Exlude mailboxes to which current user has no access.
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-w36p-9vhj-x273
$mailbox_ids = array_intersect($mailbox_ids, $auth_user->mailboxesIdsCanView());
}
$user->mailboxes()->sync($mailbox_ids);
$user->syncPersonalFolders($request->mailboxes);
// Send invite
@@ -148,7 +167,7 @@ class UsersController extends Controller
public function getUsersForSidebar($except_id)
{
if (auth()->user()->isAdmin()) {
return User::sortUsers(User::nonDeleted()->get());/*->except($except_id)*/;
return User::sortUsers(User::nonDeleted()->get()); // ->except($except_id);
} else {
return [];
}
@@ -173,7 +192,7 @@ class UsersController extends Controller
// This is also present in PublicController::userSetup
$validator = Validator::make($request->all(), [
'first_name' => 'required|string|max:20',
'last_name' => 'required|string|max:30',
'last_name' => 'max:30',
'email' => 'required|string|email|max:100|unique:users,email,'.$id,
//'emails' => 'max:100',
'job_title' => 'max:100',
@@ -259,6 +278,7 @@ class UsersController extends Controller
'role',
'status',
'email',
'only_assigned_tickets',
];
$nonfillable_fields = [
'type',
@@ -279,6 +299,14 @@ class UsersController extends Controller
$user->setData($request_data);
if ($auth_user->isAdmin() && !$user->isAdmin()) {
if (!empty($request_data['only_assigned_tickets'])) {
$user->addPermission(User::PERM_ONLY_ASSIGNED_TICKETS, false);
} else {
$user->removePermission(User::PERM_ONLY_ASSIGNED_TICKETS, false);
}
}
$user = \Eventy::filter('user.save_profile', $user, $request);
$user->save();
@@ -304,7 +332,7 @@ class UsersController extends Controller
abort(404);
}
$mailboxes = Mailbox::all();
$mailboxes = Mailbox::all()->sortBy('name');
$users = $this->getUsersForSidebar($id);
@@ -360,7 +388,7 @@ class UsersController extends Controller
public function notifications($id)
{
$user = User::findOrFail($id);
$this->authorize('update', $user);
$this->authorize('updateNotifications', $user);
if ($user->isDeleted()) {
abort(404);
@@ -393,7 +421,7 @@ class UsersController extends Controller
public function notificationsSave($id, Request $request)
{
$user = User::findOrFail($id);
$this->authorize('update', $user);
$this->authorize('updateNotifications', $user);
Subscription::saveFromArray($request->subscriptions, $user->id);
@@ -547,7 +575,7 @@ class UsersController extends Controller
$user->deleteUser($auth_user, $request->assign_user);
\Session::flash('flash_success_floating', __('User deleted').': '.$user->getFullName());
\Session::flash('flash_success_floating', __('User deleted').': '.htmlspecialchars($user->getFullName()));
$response['status'] = 'success';
}

View File

@@ -18,6 +18,7 @@ class Kernel extends HttpKernel
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\App\Http\Middleware\ResponseHeaders::class,
\App\Http\Middleware\TerminateHandler::class,
@@ -39,10 +40,19 @@ class Kernel extends HttpKernel
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\HttpsRedirect::class,
\App\Http\Middleware\CheckBrowser::class,
\App\Http\Middleware\Localize::class,
\App\Http\Middleware\LogoutIfDeleted::class,
\App\Http\Middleware\FrameGuard::class,
\App\Http\Middleware\CustomHandle::class,
\App\Http\Middleware\ContentSecurityPolicy::class,
],
'open' => [
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\HttpsRedirect::class,
\App\Http\Middleware\FrameGuard::class,
\App\Http\Middleware\CustomHandle::class,
],
// 'api' => [

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
class CheckBrowser
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null $guard
*
* @return mixed
*/
public function handle($request, Closure $next, $guard = null)
{
$result = \Helper::checkBrowser($request);
if ($result['msg']) {
if (config('app.disable_browser_check')) {
//\Log::error($result['msg']);
} else {
abort(403, __($result['msg'].'[display]'));
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
/**
* On regular pages CSP is added via <meta> tag.
* For /ajax-html/ requests CSP is added as a response header.
*/
class ContentSecurityPolicy
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null $guard
*
* @return mixed
*/
public function handle($request, Closure $next, $guard = null)
{
$response = $next($request);
if (str_contains($request->path(), '/ajax-html/')) {
$response->header('Content-Security-Policy', \Helper::getCspValue());
}
return $response;
}
}

View File

@@ -12,6 +12,7 @@ class EncryptCookies extends Middleware
* @var array
*/
protected $except = [
'in_app'
'in_app',
'customers_view',
];
}

View File

@@ -11,8 +11,8 @@ use Closure;
use Illuminate\Support\Facades\App;
use Illuminate\Http\Request;
class HttpsRedirect {
class HttpsRedirect
{
/**
* The current proxy header mappings.
*
@@ -28,7 +28,7 @@ class HttpsRedirect {
public function handle($request, Closure $next)
{
if (\Helper::isHttps()) {
if (\Helper::isHttps() && !\Helper::isConsole()) {
//$request->setTrustedProxies( [ $request->getClientIp() ], array_keys($this->headers));
//!$request->secure()
if (!\Helper::isCurrentUrlHttps()) {
@@ -45,4 +45,4 @@ class HttpsRedirect {
}
return $next($request);
}
}
}

View File

@@ -22,9 +22,13 @@ class ResponseHeaders
$response->headers->set('X-Content-Type-Options', 'nosniff');
// $response->headers->set('X-XSS-Protection', '1; mode=block');
// $response->headers->set('X-Frame-Options', 'DENY');
//$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
//$response->headers->set('Content-Security-Policy', "style-src 'self'");
// Instruct browsers to always use HTTPS for all future requests.
if (\Helper::isHttps()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
}
// Disable caching.
if (method_exists($response, 'header')) {
$response->header('Pragma', 'no-cache');

View File

@@ -18,14 +18,30 @@ class TokenAuth
public function handle($request, Closure $next)
{
// This is needed to restore authentication when app session expires.
if (!$request->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 (!$request->user() && !empty($request->auth_token) && is_string($request->auth_token) && \Helper::isInApp($request)) {
// Decode token (format: urlencode(base64_encode(user_id:expiry:hash)))
$parts = explode(':', urldecode(base64_decode($request->auth_token)));
if (count($parts) !== 3) {
return $next($request);
}
if (!empty($user)) {
list($user_id, $expiry, $token_hash) = $parts;
// Check expiration.
if (time() > (int)$expiry) {
return $next($request);
}
// Get user.
$user = User::find($user_id);
if (!$user) {
return $next($request);
}
// Verify hash.
$hash = hash_hmac('sha256', $user_id.':'.$expiry, config('app.key').$user->password);
if (hash_equals($hash, $token_hash)) {
\Auth::login($user);
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
/**
* https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-822g-7rw5-53xj
*/
class TrustHosts
{
/**
* The application instance.
*
* @var \Illuminate\Contracts\Foundation\Application
*/
protected $app;
/**
* The trusted hosts that have been configured to always be trusted.
*/
protected static $trusted_hosts;
/**
* Create a new middleware instance.
*
* @param \Illuminate\Contracts\Foundation\Application $app
*/
public function __construct(Application $app)
{
$this->app = $app;
}
/**
* Handle the incoming request.
*/
public function handle(Request $request, $next)
{
if (//! $this->app->environment('local') &&
$this->app->runningUnitTests()
) {
return $next($request);
}
// Check if current host matches APP_URL.
list($current_host) = explode(':', request()->getHttpHost());
$current_host = mb_strtolower($current_host);
$app_host = mb_strtolower(\Helper::getDomain());
// Do not check during installation process.
if ($app_host == 'example.com' && preg_match("#^install/#", $request->path())) {
return $next($request);
}
if ($current_host == $app_host) {
return $next($request);
}
// Check hosts from APP_TRUSTED_HOSTS.
$trusted_hosts = explode(',', config('app.trusted_hosts'));
foreach ($trusted_hosts as $host) {
$host = mb_strtolower(trim($host));
if ($host && $current_host == $host) {
return $next($request);
}
}
$is_trusted_host = \Eventy::filter('app.is_trusted_host', false, $current_host);
if ($is_trusted_host) {
return $next($request);
}
// Throws: Untrusted Host...
//Request::setTrustedHosts([$current_host]);
//throw new SuspiciousOperationException(sprintf('Untrusted Host "%s".', $host));
\Helper::denyAccess("Untrusted Host: {$current_host}. Add the following to your .env file and clear cache: APP_TRUSTED_HOSTS ='$current_host'", true);
//return $next($request);
}
/**
* Get a regular expression matching the application URL and all of its subdomains.
*
* @return string|null
*/
// protected function allSubdomainsOfAppUrl()
// {
// if ($host = parse_url($this->app['config']->get('app.url'), PHP_URL_HOST)) {
// return '^(.+\.)?'.preg_quote($host).'$';
// }
// }
}

View File

@@ -27,9 +27,9 @@ class Job extends Model
return $this->payload_decoded;
}
public function getCommand()
public function getCommand($allowed_classes = [])
{
return self::getPayloadCommand($this->getPayloadDecoded());
return self::getPayloadCommand($this->getPayloadDecoded(), $allowed_classes);
}
public function getCommandLastThread()
@@ -42,17 +42,32 @@ class Job extends Model
return null;
}
public static function getPayloadCommand($payload)
public static function getPayloadCommand($payload, $allowed_classes = [])
{
if (empty($payload['data']) || empty($payload['data']['command'])) {
return null;
}
if (!$allowed_classes) {
$allowed_classes = [
'App\Jobs\SendReplyToCustomer',
'App\Jobs\SendNotificationToUsers',
'App\Jobs\SendAutoReply',
'App\Jobs\SendAlert',
'App\Jobs\SendEmailReplyError',
'Illuminate\Contracts\Database\ModelIdentifier',
];
}
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']);
return unserialize($payload['data']['command'], ['allowed_classes' => $allowed_classes]);
} catch (\Exception $e) {
return null;
}
}
public static function getTriggerActionName($payload)
{
return preg_replace('/^.*?action";s:\d+:"([^"]+)".*$/s', '$1', $payload['data']['command'] ?? '');
}
}

View File

@@ -77,9 +77,23 @@ class SendAutoReply implements ShouldQueue
$failures = [];
$exception = null;
// Reply from the alias the customer emailed (if "Allow to reply from aliases" is enabled).
$from_alias = '';
$aliases = $this->mailbox->getAliases(true, true);
if (count($aliases)) {
$original_recipients = array_merge($this->thread->getToArray(), $this->thread->getCcArray());
foreach ($original_recipients as $original_recipient) {
$original_recipient = \App\Email::sanitizeEmail($original_recipient);
if ($original_recipient && array_key_exists($original_recipient, $aliases)) {
$from_alias = $original_recipient;
break;
}
}
}
try {
Mail::to([['name' => $this->customer->getFullName(), 'email' => $customer_email]])
->send(new AutoReply($this->conversation, $this->mailbox, $this->customer, $headers));
->send(new AutoReply($this->conversation, $this->mailbox, $this->customer, $headers, $from_alias));
} catch (\Exception $e) {
// We come here in case SMTP server unavailable for example
activity()

View File

@@ -24,6 +24,8 @@ class SendEmailReplyError implements ShouldQueue
public $mailbox;
public $text;
/**
* The number of seconds the job can run before timing out.
* fwrite() function in /vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/StreamBuffer.php
@@ -37,11 +39,12 @@ class SendEmailReplyError implements ShouldQueue
*
* @return void
*/
public function __construct($from, $user, $mailbox)
public function __construct($from, $user, $mailbox, $text = null)
{
$this->from = $from;
$this->user = $user;
$this->mailbox = $mailbox;
$this->text = $text;
}
/**
@@ -58,7 +61,7 @@ class SendEmailReplyError implements ShouldQueue
try {
Mail::to([['name' => '', 'email' => $this->from]])
->send(new UserEmailReplyError());
->send(new UserEmailReplyError($this->text));
} catch (\Exception $e) {
// We come here in case SMTP server unavailable for example
activity()

View File

@@ -96,10 +96,13 @@ class SendNotificationToUsers implements ShouldQueue
$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;
$last_send_exception = null;
foreach ($this->users as $user) {
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-2j2c-g32w-96vm
$user->refresh();
// User can ne deleted from DB.
if (!isset($user->id)) {
continue;
@@ -109,6 +112,10 @@ class SendNotificationToUsers implements ShouldQueue
continue;
}
if (!$user->can('view', $this->conversation)) {
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) {
@@ -123,7 +130,7 @@ class SendNotificationToUsers implements ShouldQueue
}
}
$message_id = \App\Misc\Mail::MESSAGE_ID_PREFIX_NOTIFICATION.'-'.$last_thread->id.'-'.$user->id.'-'.time().'@'.$mailbox->getEmailDomain();
$message_id = \App\Misc\Mail::MESSAGE_ID_PREFIX_NOTIFICATION.'-'.$last_thread->id.'-'.$user->id.'-'.\MailHelper::getMessageIdHash($last_thread->id).'@'.$mailbox->getEmailDomain();
$headers['Message-ID'] = $message_id;
// If this is notification on message from customer, set customer as sender name
@@ -154,17 +161,18 @@ class SendNotificationToUsers implements ShouldQueue
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
// We come here if SMTP server unavailable for example
activity()
->causedBy($user)
->withProperties([
'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
'conversation' => '#'.$this->conversation->number.'-'.($last_thread ? $last_thread->id : ''),
])
->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_USER);
$exception = $e;
$global_exception = $e;
$last_send_exception = $e;
}
$status_message = '';
@@ -185,7 +193,7 @@ class SendNotificationToUsers implements ShouldQueue
SendLog::log($last_thread->id, $message_id, $user->email, SendLog::MAIL_TYPE_USER_NOTIFICATION, $status, null, $user->id, $status_message);
}
if ($global_exception) {
if ($last_send_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
@@ -194,13 +202,24 @@ class SendNotificationToUsers implements ShouldQueue
// Second attempt after 5 min.
$this->release(300);
} else {
// Mail data has been passed to the mail server but after that
// we have not receved a response from the mail server (due to timeout).
// So we may consider that the email has been successfully sent.
// Otherwise this email may be sent again and again.
if ($this->attempts() >= 3
&& preg_match("#^Connection to .* Timed Out$#", $last_send_exception->getMessage())
&& \MailHelper::$smtp_data_sent
) {
$this->fail($last_send_exception);
}
// Others - after 1 hour.
$this->release(3600);
}
throw $global_exception;
throw $last_send_exception;
} else {
$this->fail($global_exception);
$this->fail($last_send_exception);
return;
}

View File

@@ -23,8 +23,12 @@ class SendReplyToCustomer implements ShouldQueue
public $threads;
// Recipient.
public $customer;
// Needed to show proper signature when conversation is being moved between mailboxes.
public $mailbox_change_history = [];
private $failures = [];
private $recipients = [];
private $last_thread = null;
@@ -51,6 +55,7 @@ class SendReplyToCustomer implements ShouldQueue
{
$this->conversation = $conversation;
$this->threads = $threads;
// Recipient.
$this->customer = $customer;
}
@@ -82,7 +87,7 @@ class SendReplyToCustomer implements ShouldQueue
if ($forward_child_thread->isForwarded() && $forward_child_thread->getForwardParentConversation()) {
// Add replies from original conversation.
$forwarded_replies = $forward_child_thread->getForwardParentConversation()->getReplies();
$forwarded_replies = $forward_child_thread->getForwardParentConversation()->getThreads(null, null, [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_LINEITEM]);
$forwarded_replies = Thread::sortThreads($forwarded_replies);
$forward_parent_thread = Thread::find($forward_child_thread->getMetaFw(Thread::META_FORWARD_PARENT_THREAD_ID));
@@ -95,6 +100,22 @@ class SendReplyToCustomer implements ShouldQueue
}
$this->threads = $this->threads->merge($forwarded_replies);
$is_forward = true;
// In order to show proper signature.
// https://github.com/freescout-help-desk/freescout/issues/5419
$new_mailbox_id = null;
foreach ($this->threads as $reply) {
if ($reply->action_type == Thread::ACTION_TYPE_MOVED_FROM_MAILBOX && is_numeric($reply->action_data)) {
$new_mailbox_id = (int)$reply->action_data;
}
if ($reply->isReply() && $new_mailbox_id !== null) {
$this->mailbox_change_history[$reply->id] = $new_mailbox_id;
$new_mailbox_id = null;
}
}
// Now remove line items.
$this->threads = $this->threads->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE]);
}
}
}
@@ -146,7 +167,9 @@ class SendReplyToCustomer implements ShouldQueue
$references = '';
if (!$new && !empty($last_customer_thread) && $last_customer_thread->message_id) {
$headers['In-Reply-To'] = '<'.$last_customer_thread->message_id.'>';
// Always put real Message-ID into In-Reply-To field.
// https://github.com/freescout-help-desk/freescout/issues/5620
$headers['In-Reply-To'] = '<'.$last_customer_thread->getMessageId().'>';
//$headers['References'] = '<'.$last_customer_thread->message_id.'>';
// https://github.com/freescout-helpdesk/freescout/issues/3175
$i = 0;
@@ -161,7 +184,36 @@ class SendReplyToCustomer implements ShouldQueue
$i++;
}
if ($references_array) {
$references = '<'.implode('> <', array_reverse($references_array)).'>';
// Trim the list of References: some SMTP relays reject messages
// whose headers block exceeds 4096 chars.
// Preserve the first and the last reference.
// https://github.com/freescout-help-desk/freescout/issues/5542
$max_references_length = 1500;
$recent = [];
$recent[] = $references_array[0];
$length = strlen($references_array[0]) + 3;
foreach ($references_array as $i => $reference) {
// The first and the last Reference are added manually.
if ($i == 0 || $i == count($references_array)-1) {
continue;
}
$length += strlen($reference) + 3;
if ($length > $max_references_length) {
break;
}
$recent[] = $reference;
}
// Append the last.
if (!empty($references_array[count($references_array)-1])) {
$recent[] = $references_array[count($references_array)-1];
}
// Oldest first, per RFC 5322 3.6.4.
$references_array = array_reverse($recent);
$references = '<'.implode('> <', $references_array).'>';
}
if ($references) {
$headers['References'] = $references;
@@ -173,7 +225,13 @@ class SendReplyToCustomer implements ShouldQueue
if ($last_customer_thread) {
$outloook_thread_index = $last_customer_thread->getHeader('Thread-Index');
if (!empty($outloook_thread_index)) {
$headers['Thread-Index'] = $outloook_thread_index;
// A reply must extend the parent's conversation index with
// a 5-byte child block (MS-OXOMSG 2.2.1.3) rather than echo
// it verbatim, otherwise Outlook files the reply as a new
// conversation and the customer's original message is left
// on its own. Falls back to the verbatim copy if the value
// does not decode as a conversation index.
$headers['Thread-Index'] = self::extendThreadIndex($outloook_thread_index);
$outloook_thread_topic = $last_customer_thread->getHeader('Thread-Topic');
if ($outloook_thread_topic) {
$headers['Thread-Topic'] = $outloook_thread_topic;
@@ -222,7 +280,7 @@ class SendReplyToCustomer implements ShouldQueue
}
// Configure mail driver according to Mailbox settings
\MailHelper::setMailDriver($mailbox, $this->last_thread->created_by_user, $this->conversation);
\MailHelper::setMailDriver($mailbox, $this->last_thread->created_by_user, $this->conversation, $this->last_thread);
// https://github.com/freescout-helpdesk/freescout/issues/3330
if (!\MailHelper::$smtp_queue_id_plugin_registered) {
@@ -233,7 +291,12 @@ class SendReplyToCustomer implements ShouldQueue
$this->message_id = $this->last_thread->getMessageId($mailbox);
$headers['Message-ID'] = $this->message_id;
$this->customer_email = $this->conversation->customer_email;
// https://github.com/freescout-help-desk/freescout/issues/5121
if ($this->customer->id == $this->conversation->customer_id) {
$this->customer_email = $this->conversation->customer_email;
} else {
$this->customer_email = $this->last_thread->getToArray()[0] ?: $this->conversation->customer_email;
}
// For phone conversations we may need to get customer email.
// https://github.com/freescout-helpdesk/freescout/issues/3270
@@ -247,7 +310,9 @@ class SendReplyToCustomer implements ShouldQueue
// Try to get customer by email
if (!$this->customer) {
$this->customer = Customer::getByEmail($this->customer_email);
return;
if (!$this->customer) {
return;
}
}
$to_array = $mailbox->removeMailboxEmailsFromList($this->last_thread->getToArray());
@@ -295,7 +360,7 @@ class SendReplyToCustomer implements ShouldQueue
$headers['X-FreeScout-Mail-Type'] = 'customer.message';
$reply_mail = new ReplyToCustomer($this->conversation, $this->threads, $headers, $mailbox, $subject, $threads_count);
$reply_mail = new ReplyToCustomer($this->conversation, $this->threads, $headers, $mailbox, $subject, $threads_count, $this->mailbox_change_history);
$smtp_queue_id = null;
@@ -316,6 +381,8 @@ class SendReplyToCustomer implements ShouldQueue
->causedBy($this->customer)
->withProperties([
'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
'to' => $this->customer_email,
'conversation' => '#'.$this->conversation->number.'-'.$this->last_thread->id,
])
->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
@@ -471,7 +538,7 @@ class SendReplyToCustomer implements ShouldQueue
try {
// https://github.com/freescout-helpdesk/freescout/issues/3502
$imap_sent_folder = mb_convert_encoding($imap_sent_folder, "UTF7-IMAP","UTF-8");
$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')) {
@@ -570,11 +637,18 @@ class SendReplyToCustomer implements ShouldQueue
*/
public function failed(\Exception $e)
{
// failed() is called by unserializing the original queue payload.
if (!$this->last_thread && $this->threads && count($this->threads)) {
$this->threads = Thread::sortThreads($this->threads);
$this->last_thread = $this->threads->first();
}
activity()
->causedBy($this->customer)
->withProperties([
'error' => $e->getMessage().'; File: '.$e->getFile().' ('.$e->getLine().')',
'to' => $this->customer_email,
'conversation' => '#'.$this->conversation->number.'-'.($this->last_thread ? $this->last_thread->id : ''),
])
->useLog(\App\ActivityLog::NAME_EMAILS_SENDING)
->log(\App\ActivityLog::DESCRIPTION_EMAILS_SENDING_ERROR_TO_CUSTOMER);
@@ -603,4 +677,41 @@ class SendReplyToCustomer implements ShouldQueue
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);
}
}
/**
* Append a 5-byte child block to an Outlook conversation index
* (Thread-Index header, MS-OXOMSG 2.2.1.3) so the reply nests under the
* customer's message instead of being read by Outlook as a new
* conversation. Header = 0x01 + 5 bytes of FILETIME (bits 16-55) + 16-byte
* GUID; each child = code(1 bit) + time delta(31) + random(4) + sequence(4).
* Returns the input unchanged when it does not decode as a valid index.
* https://github.com/freescout-help-desk/freescout/issues/4922
*/
public static function extendThreadIndex($thread_index)
{
$raw = base64_decode(trim((string)$thread_index), true);
if ($raw === false || strlen($raw) < 22 || (strlen($raw) - 22) % 5 !== 0) {
return $thread_index;
}
// Header timestamp: FILETIME bits 16-55, big-endian, in bytes 1-5.
$hdr = unpack('Nhi/Clo', substr($raw, 1, 5));
$hdr_bits = ($hdr['hi'] << 8) | $hdr['lo'];
$now_ft = (int) round((microtime(true) + 11644473600) * 10000000);
$now_bits = ($now_ft >> 16) & 0xFFFFFFFFFF;
$delta = ($now_bits - $hdr_bits) << 16;
if ($delta < 0) {
$delta = 0;
}
$code = 0;
$diff = $delta >> 18;
if ($diff > 0x7FFFFFFF) {
$code = 1;
$diff = min($delta >> 23, 0x7FFFFFFF);
}
// 40-bit child block: code(1) | diff(31) | random(4) | sequence(4)=0
$block = (($code << 39) | ($diff << 8) | (random_int(0, 15) << 4)) & 0xFFFFFFFFFF;
$bytes = pack('C5', ($block >> 32) & 0xFF, ($block >> 24) & 0xFF, ($block >> 16) & 0xFF, ($block >> 8) & 0xFF, $block & 0xFF);
return base64_encode($raw.$bytes);
}
}

View File

@@ -25,6 +25,9 @@ class RefreshConversations
*/
public function handle($event)
{
Conversation::refreshConversations($event->conversation, $event->thread ?? $event->last_thread);
$thread = $event->thread ?? $event->last_thread;
if ($thread) {
Conversation::refreshConversations($event->conversation, $thread);
}
}
}

View File

@@ -44,7 +44,7 @@ class SendAutoReply
}
// Do not send auto replies to spam messages.
if ($conversation->status == Conversation::STATUS_SPAM) {
if ($conversation->isSpam()) {
return;
}

View File

@@ -26,6 +26,7 @@ class SendNotificationToUsers
{
$event_type = null;
$caused_by_user_id = null;
$extra_data = [];
// Detect event type by event class
switch (get_class($event)) {
@@ -47,7 +48,7 @@ class SendNotificationToUsers
break;
case 'App\Events\CustomerCreatedConversation':
// Do not send notification if conversation is spam.
if ($event->conversation->status != Conversation::STATUS_SPAM) {
if (!$event->conversation->isSpam()) {
$event_type = Subscription::EVENT_TYPE_NEW;
}
break;
@@ -55,7 +56,17 @@ class SendNotificationToUsers
$caused_by_user_id = $event->user->id;
$event_type = Subscription::EVENT_TYPE_ASSIGNED;
break;
case 'App\Events\UserMovedConversation':
$caused_by_user_id = $event->caused_by_user_id;
$extra_data['from_mailbox'] = $event->from_mailbox;
$event_type = Subscription::EVENT_TYPE_USER_MOVED;
break;
case 'App\Events\CustomerReplied':
// Do not send notifications to users if customer sent a reply
// to the conversation marked as Spam.
if (!empty($event->conversation) && $event->conversation->isSpam()) {
return;
}
$event_type = Subscription::EVENT_TYPE_CUSTOMER_REPLIED;
break;
}
@@ -70,6 +81,6 @@ class SendNotificationToUsers
$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*/);
Subscription::registerEvent($event_type, $conversation, $caused_by_user_id, $extra_data);
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Listeners;
use App\Conversation;
use App\Customer;
use App\Thread;
class SendReplyToCustomer
{
@@ -23,14 +25,20 @@ class SendReplyToCustomer
{
$conversation = $event->conversation;
$main_customer_email = $conversation->customer->getMainEmail();
// Do not send email if this is a Phone conversation and customer has no email.
if ($conversation->isPhone()) {
if (!$conversation->customer->getMainEmail()) {
if (!$main_customer_email) {
return;
}
}
$replies = $conversation->getReplies();
// Get threads with line items, line items are needed
// to show proper signature when conversation is being moved
// between mailboxes.
//$replies = $conversation->getReplies();
$replies = $conversation->getThreads(null, null, [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE, Thread::TYPE_LINEITEM]);
// Ignore imported messages.
if ($replies && $replies->first() && $replies->first()->imported) {
@@ -47,6 +55,33 @@ class SendReplyToCustomer
$replies->forget($i);
}
}
} else {
// If thread is not provided - stop.
// It allows to trigger event but avoid sending an email to customer.
return;
}
// In order to show proper signature.
// https://github.com/freescout-help-desk/freescout/issues/5419
$mailbox_change_history = [];
$new_mailbox_id = null;
foreach ($replies as $i => $reply) {
if ($reply->action_type == Thread::ACTION_TYPE_MOVED_FROM_MAILBOX && is_numeric($reply->action_data)) {
$new_mailbox_id = (int)$reply->action_data;
}
if ($reply->isReply() && $new_mailbox_id !== null) {
$mailbox_change_history[$reply->id] = $new_mailbox_id;
$new_mailbox_id = null;
}
}
// Now remove line items.
$replies = $replies->whereIn('type', [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE]);
// Allow to cancel mail sending if needed.
$skip_send = \Eventy::filter('conversation.skip_send_reply_to_customer', false, $conversation, $replies);
if ($skip_send) {
return;
}
// Chat conversation.
@@ -60,7 +95,23 @@ class SendReplyToCustomer
//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)
$recipient_customer = $conversation->customer;
// The reply may be sent to some other customer from previous threads.
// https://github.com/freescout-help-desk/freescout/pull/5199
if ($thread
&& ($to_array = $thread->getToArray())
&& !empty($to_array[0])
&& ($customer_email = $to_array[0])
&& $customer_email != $main_customer_email
) {
$other_customer = Customer::getByEmail($customer_email);
if ($other_customer) {
$recipient_customer = $other_customer;
}
}
\App\Jobs\SendReplyToCustomer::dispatch($conversation, $replies, $recipient_customer)
->delay($delay)
->onQueue('emails');
}

View File

@@ -26,15 +26,21 @@ class AutoReply extends Mailable
*/
public $headers = [];
/**
* Mailbox alias to send From (empty to use mailbox default email).
*/
public $from_alias = '';
/**
* Create a new message instance.
*/
public function __construct($conversation, $mailbox, $customer, $headers)
public function __construct($conversation, $mailbox, $customer, $headers, $from_alias = '')
{
$this->conversation = $conversation;
$this->mailbox = $mailbox;
$this->customer = $customer;
$this->headers = $headers;
$this->from_alias = $from_alias;
}
/**
@@ -77,15 +83,54 @@ class AutoReply extends Mailable
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']);
$from_alias = $this->from_alias;
if (!empty($new_headers) || $from_alias) {
$mailbox = $this->mailbox;
$conversation = $this->conversation;
$this->withSwiftMessage(function ($swiftmessage) use ($new_headers, $from_alias, $mailbox, $conversation) {
$headers = null;
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);
}
}
}
$headers = $swiftmessage->getHeaders();
foreach ($new_headers as $header => $value) {
if ($header != 'Message-ID') {
$headers->addTextHeader($header, $value);
if (!empty($from_alias)) {
$aliases = $mailbox->getAliases();
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(null, $conversation);
if ($mailbox_mail_from['name'] == $mailbox->name && $from_alias_name) {
// Use name from alias.
} else {
$from_alias_name = $mailbox_mail_from['name'];
}
if (!$headers) {
$headers = $swiftmessage->getHeaders();
}
$swift_from = $headers->get('From');
if ($from_alias_name) {
$swift_from->setNameAddresses([
$from_alias => $from_alias_name,
]);
} else {
$swift_from->setAddresses([
$from_alias,
]);
}
}
}

View File

@@ -48,12 +48,17 @@ class ReplyToCustomer extends Mailable
*/
public $threads_count;
/**
* Needed to show proper signature when conversation is being moved between mailboxes.
*/
public $mailbox_change_history;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct($conversation, $threads, $headers, $mailbox, $subject, $threads_count = 1)
public function __construct($conversation, $threads, $headers, $mailbox, $subject, $threads_count = 1, $mailbox_change_history = [])
{
$this->conversation = $conversation;
$this->threads = $threads;
@@ -61,6 +66,7 @@ class ReplyToCustomer extends Mailable
$this->mailbox = $mailbox;
$this->subject = $subject;
$this->threads_count = $threads_count;
$this->mailbox_change_history = $mailbox_change_history;
}
/**
@@ -123,11 +129,11 @@ class ReplyToCustomer extends Mailable
if ($from_alias_name) {
$swift_from->setNameAddresses([
$from_alias => $from_alias_name
$from_alias => $from_alias_name,
]);
} else {
$swift_from->setAddresses([
$from_alias
$from_alias,
]);
}
}
@@ -143,8 +149,8 @@ class ReplyToCustomer extends Mailable
});
}
$template_html = \Eventy::filter('email.reply_to_customer.template_name_html','emails/customer/reply_fancy');
$template_text = \Eventy::filter('email.reply_to_customer.template_name_text','emails/customer/reply_fancy_text');
$template_html = \Eventy::filter('email.reply_to_customer.template_name_html', 'emails/customer/reply_fancy');
$template_text = \Eventy::filter('email.reply_to_customer.template_name_text', 'emails/customer/reply_fancy_text');
// from($this->from) Sets only email, name stays empty.
// So we set from in Mail::setMailDriver
@@ -154,10 +160,14 @@ class ReplyToCustomer extends Mailable
if ($thread->has_attachments) {
foreach ($thread->attachments as $attachment) {
if ($attachment->fileExists()) {
$message->attach($attachment->getLocalFilePath());
if (\Helper::isLocalStorage()) {
if ($attachment->fileExists()) {
$message->attach($attachment->getLocalFilePath());
} else {
\Log::error('[ReplyToCustomer] Thread: '.$thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
}
} else {
\Log::error('[ReplyToCustomer] Thread: '.$thread->id.'. Attachment file not find on disk: '.$attachment->getLocalFilePath());
\Eventy::filter('email.reply_to_customer.attach', false, $message, $attachment, $thread->id);
}
}
}

View File

@@ -9,13 +9,16 @@ use Illuminate\Mail\Mailable;
class UserEmailReplyError extends Mailable
{
public $text;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct()
public function __construct($text = null)
{
$this->text = $text;
}
/**

View File

@@ -3,6 +3,7 @@
namespace App;
use App\Email;
use App\Thread;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
@@ -14,6 +15,12 @@ class Mailbox extends Model
// This is obligatory.
public $rememberCacheDriver = 'array';
/**
* State.
*/
const STATE_ACTIVE = 1;
const STATE_ARCHIVED = 2;
/**
* From Name: name that will appear in the From field when a customer views your email.
*/
@@ -132,6 +139,8 @@ class Mailbox extends Model
const DEFAULT_SIGNATURE = '<br><span style="color:#808080;">--<br>
{%mailbox.name%}</span>';
const META_STATE = 'st';
/**
* Default values.
*/
@@ -217,6 +226,23 @@ class Mailbox extends Model
}
}
/**
* Get mailbox state: stored in meta for now.
*/
public function getStateAttribute()
{
return $this->getMeta(self::META_STATE, self::STATE_ACTIVE);
}
public function setStateAttribute($value)
{
$value = (int)$value;
if (!in_array($value, [self::STATE_ACTIVE, self::STATE_ARCHIVED])) {
$value = self::STATE_ACTIVE;
}
$this->setMeta(self::META_STATE, $value);
}
/**
* Get users having access to the mailbox.
*/
@@ -402,12 +428,22 @@ class Mailbox extends Model
}
}
public function isActive()
{
return $this->state == self::STATE_ACTIVE;
}
public function isArchived()
{
return $this->state == self::STATE_ARCHIVED;
}
/**
* Is mailbox available for using.
*
* @return bool
*/
public function isActive()
public function isConnected()
{
return $this->isInActive() && $this->isOutActive();
}
@@ -543,6 +579,9 @@ class Mailbox extends Model
$user = User::find($user_id);
}
}
if (!$user->isAdmin() && !$this->isActive()) {
return false;
}
$filter = \Eventy::filter('mailbox.user_has_access', -1, $this, $user);
if ($filter != -1) {
return (bool)$filter;
@@ -561,7 +600,7 @@ class Mailbox extends Model
*
* @return array
*/
public function getMailFrom($from_user = null, $conversation = null)
public function getMailFrom($from_user = null, $conversation = null, $thread = null)
{
// Mailbox name by default
$name = $this->name;
@@ -579,8 +618,8 @@ class Mailbox extends Model
}
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)
'address' => \Eventy::filter('mailbox.get_mail_from_address', $this->email, $from_user, $conversation, $thread, $this),
'name' => \Eventy::filter('mailbox.get_mail_from_name', $name, $from_user, $conversation, $thread, $this)
];
}
@@ -669,6 +708,22 @@ class Mailbox extends Model
return \Eventy::filter('mailbox.in_protocols', self::$in_protocols);
}
/**
* Determines if Fetching settings for the mailbox have been saved by admin.
*/
public function inSettingsSaved()
{
return ($this->attributes['in_password'] !== null);
}
/**
* Determines if Sending settings for the mailbox have been saved by admin.
*/
public function outSettingsSaved()
{
return ($this->attributes['out_password'] !== null);
}
/**
* Get pivot table parameters for the user.
*/
@@ -684,7 +739,8 @@ class Mailbox extends Model
}
/**
* Create dummy object with default parameters
* Create dummy object with default parameters.
*
* @return [type] [description]
*/
public static function getDummySettings()
@@ -793,7 +849,7 @@ class Mailbox extends Model
}
/**
* Get all active mailboxes.
* Get all active & connected mailboxes.
*
* @return [type] [description]
*/
@@ -804,7 +860,7 @@ class Mailbox extends Model
// It is more effective to retrive all mailboxes and filter them in PHP.
$mailboxes = self::all();
foreach ($mailboxes as $mailbox) {
if ($mailbox->isActive()) {
if ($mailbox->isActive() && $mailbox->isConnected()) {
$active[] = $mailbox;
}
}
@@ -930,6 +986,15 @@ class Mailbox extends Model
public function setMetaParam($param, $value, $save = false)
{
// Encrypt some values.
if ($param == 'oauth' && is_array($value)) {
if (!empty($value['a_token'])) {
$value['a_token'] = \Helper::encrypt($value['a_token']);
}
if (!empty($value['r_token'])) {
$value['r_token'] = \Helper::encrypt($value['r_token']);
}
}
$meta = $this->meta;
$meta[$param] = $value;
$this->meta = $meta;
@@ -974,20 +1039,84 @@ class Mailbox extends Model
public function oauthGetParam($param)
{
return $this->meta['oauth'][$param] ?? '';
$value = $this->meta['oauth'][$param] ?? '';
// Decrypt some values.
if (in_array($param, ['a_token', 'r_token'])) {
$value = \Helper::decrypt($value);
}
return $value;
}
public function inOauthEnabled()
{
return $this->oauthEnabled()
&& $this->in_username !== null && !strstr($this->in_username, '@');
&& $this->in_username !== null
&& $this->isInUsernameOauth();
}
public function outOauthEnabled()
{
return $this->oauthEnabled()
&& $this->out_username !== null && !strstr($this->out_username, '@')
&& $this->out_server !== null && trim($this->out_server) == \MailHelper::OAUTH_MICROSOFT_SMTP;
&& $this->out_username !== null
&& $this->isOutUsernameOauth()
&& $this->out_server !== null
&& $this->isOutServerOauth();
}
// For oAuth Username may have the following format:
// test@example.org:123-456-789
public function getInOauthUsername()
{
$username = preg_replace("#:.*#", '', $this->in_username ?? '');
if (strstr($username, '@')) {
return $username;
} else {
return $this->email;
}
}
public function getInOauthClientId()
{
return preg_replace("#.*:#", '', $this->in_username ?? '');
}
public function getOutOauthUsername()
{
$username = preg_replace("#:.*#", '', $this->out_username ?? '');
if (strstr($username, '@')) {
return $username;
} else {
return $this->email;
}
}
public function getOutOauthClientId()
{
return preg_replace("#.*:#", '', $this->out_username ?? '');
}
public function isInUsernameOauth()
{
return (!strstr($this->in_username, '@') || preg_match("#.*@.*:.*#", $this->in_username));
}
public function isOutUsernameOauth()
{
return (!strstr($this->out_username, '@') || preg_match("#.*@.*:.*#", $this->out_username));
}
public function isOauthProvider($provider)
{
return ($this->oauthGetParam('provider') == $provider);
}
public function isOutServerOauth()
{
return in_array(trim($this->out_server), [\MailHelper::OAUTH_MICROSOFT_SMTP, \MailHelper::OAUTH_GOOGLE_SMTP]);
}
public function setEmailAttribute($value)
@@ -996,4 +1125,61 @@ class Mailbox extends Model
$this->attributes['email'] = Email::sanitizeEmail($value);
}
}
public function deleteMailbox()
{
// Remove threads and conversations.
$conversation_ids = $this->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();
// }
// $this->conversations()->delete();
Conversation::deleteConversationsForever($conversation_ids);
$this->users()->sync([]);
$this->folders()->delete();
// Maybe remove notifications on events in this mailbox?
$this->delete();
}
// Purify signature HTML to avoid sending unsafe HTML to customers by email.
public static function sanitizeSignature($signature)
{
$signature = \Helper::purifyHtml($signature);
// Decode URL encoded variables back.
// https://github.com/freescout-help-desk/freescout/issues/5565
$signature = strtr($signature, [
'%7B%25' => '{%',
'%25%7D' => '%}',
]);
return $signature;
}
public static function excludeArchived($mailboxes)
{
if (is_array($mailboxes)) {
// Array.
$found = false;
foreach ($mailboxes as $i => $mailbox) {
if ($mailbox->isArchived()) {
$found = true;
unset($mailboxes[$i]);
}
}
if ($found) {
$mailboxes = array_values($mailboxes);
}
} else {
// Collection.
$mailboxes = $mailboxes->reject(function ($mailbox) {
return $mailbox->isArchived();
})->values();
}
return $mailboxes;
}
}

View File

@@ -15,13 +15,33 @@ if (! function_exists('__j')) {
}
/**
* Translate the given message and prepare it for using in HTML.
*
* Translates and escapes the text for using in HTML.
*/
function __h($key, $replace = [], $locale = null)
function __h($key, $replace = [], $locale = null, $escape_replacements = false)
{
$text = __($key, $replace, $locale);
if ($escape_replacements) {
foreach ($replace as $key => $value) {
$replace[$key] = htmlspecialchars($value);
}
}
return __(htmlspecialchars($key), $replace, $locale);
}
return htmlspecialchars($text);
/**
* Strips unsafe tags.
*/
function safe_raw_html($text, $allowed_tags = [])
{
return \Helper::stripDangerousTags($text ?? '', $allowed_tags);
}
/**
* Translates and strips unsafe tags.
*/
function __safe_raw_html($key, $replace = [], $locale = null, $allowed_tags = [])
{
$text = __($key ?? '', $replace, $locale);
return \Helper::stripDangerousTags($text, $allowed_tags);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,11 +19,20 @@ class Mail
/**
* Message-ID prefixes for outgoing emails.
* Message-IDs should not match Spam Assasin patters.
* https://github.com/freescout-help-desk/freescout/issues/5245
*/
const MESSAGE_ID_PREFIX_NOTIFICATION = 'notify';
const MESSAGE_ID_PREFIX_NOTIFICATION_IN_REPLY = 'conversation';
const MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER = 'reply';
const MESSAGE_ID_PREFIX_AUTO_REPLY = 'autoreply';
const MESSAGE_ID_PREFIX_NOTIFICATION = 'FS_notify';
const MESSAGE_ID_PREFIX_NOTIFICATION_IN_REPLY = 'FS_conversation';
const MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER = 'FS_reply';
const MESSAGE_ID_PREFIX_AUTO_REPLY = 'FS_autoreply';
public static $all_message_id_prefixes = [
self::MESSAGE_ID_PREFIX_NOTIFICATION,
self::MESSAGE_ID_PREFIX_NOTIFICATION_IN_REPLY,
self::MESSAGE_ID_PREFIX_REPLY_TO_CUSTOMER,
self::MESSAGE_ID_PREFIX_AUTO_REPLY,
];
/**
* Mail drivers.
@@ -51,6 +60,16 @@ class Mail
const OAUTH_PROVIDER_MICROSOFT = 'ms';
const OAUTH_MICROSOFT_SMTP = 'smtp.office365.com';
// Google Workspace
const OAUTH_PROVIDER_GOOGLE = 'gw';
const OAUTH_GOOGLE_SMTP = 'smtp.gmail.com';
/**
* Number of messages to fetch the headers of at a time
* when scanning a folder in findMessageByHeaders().
*/
const SCAN_CHUNK_SIZE = 50;
/**
* If reply is not extracted properly from the incoming email, add here a new separator.
* Order is not important.
@@ -58,6 +77,8 @@ class Mail
* Regex separators has "regex:" in the beginning.
*/
public static $alternative_reply_separators = [
// https://github.com/freescout-help-desk/freescout/issues/5504
'regex:/<\s*div[^>]+'.self::REPLY_SEPARATOR_HTML.'[^>]+>/',
self::REPLY_SEPARATOR_HTML, // Our HTML separator
self::REPLY_SEPARATOR_TEXT, // Our plain text separator
@@ -73,6 +94,8 @@ class Mail
'------------------ 原始邮件 ------------------', // QQ
'------------------ Original ------------------', // QQ English
'<div id=3D"divRplyFwdMsg" dir=', // Outlook
'<div id="divRplyFwdMsg" dir=', // Outlook
'regex:/<hr[^>]*>\s*<div[^>]+id="divRplyFwdMsg"/', // Outlook
'regex:/<div style="border:none;border\-top:solid \#[A-Z0-9]{6} 1\.0pt;padding:3\.0pt 0in 0in 0in">[^<]*<p class="MsoNormal"><b>/', // MS Outlook
// https://github.com/freescout-help-desk/freescout/issues/4629#issuecomment-2870297514
'regex:/<div style="border:none;border\-top:solid \#[A-Z0-9]{6} 1\.0pt;padding:3\.0pt 0cm 0cm 0cm">[^<]*<p class="MsoNormal"><b>/', // MS Outlook
@@ -128,6 +151,11 @@ class Mail
*/
public static $smtp_mime_message = '';
/**
* Indicates that DATA command and mail content has been sent to the mail server.
*/
public static $smtp_data_sent = false;
/**
* Configure mail sending parameters.
*
@@ -145,13 +173,17 @@ class Mail
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->out_username,
$token_data = \MailHelper::oauthGetAccessToken($mailbox->oauthGetParam('provider'), [
'client_id' => $mailbox->getOutOauthClientId(),
'client_secret' => $mailbox->out_password,
'refresh_token' => $mailbox->oauthGetParam('r_token'),
]);
if (!empty($token_data['a_token'])) {
// In Google Workspace new refresh token is not returned after refreshing the token.
if (empty($token_data['r_token'])) {
$token_data['r_token'] = $mailbox->oauthGetParam('r_token');
}
$mailbox->setMetaParam('oauth', $token_data, true);
} elseif (!empty($token_data['error'])) {
$error_message = 'Error occurred refreshing oAuth Access Token: '.$token_data['error'];
@@ -174,9 +206,10 @@ class Mail
\Config::set('mail.port', $mailbox->out_port);
if ($oauth) {
\Config::set('mail.auth_mode', 'XOAUTH2');
\Config::set('mail.username', $mailbox->email);
\Config::set('mail.username', $mailbox->getOutOauthUsername());
\Config::set('mail.password', $mailbox->oauthGetParam('a_token'));
} else {
\Config::set('mail.auth_mode', '');
if (!$mailbox->out_username) {
\Config::set('mail.username', null);
\Config::set('mail.password', null);
@@ -307,7 +340,7 @@ class Mail
);
// Add fallback values to the $vars array, if present.
foreach($matches['var'] as $i => $var) {
foreach ($matches['var'] as $i => $var) {
$merge_code = "{%{$var}%}";
$full_match = $matches[0][$i];
$has_fallback = false !== strpos($full_match, ',fallback=');
@@ -477,20 +510,13 @@ class Mail
// 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();
}
$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;
}
$last_error = $client->getLastError();
}
if ($last_error) {
@@ -722,33 +748,54 @@ class Mail
if (function_exists('imap_utf8')) {
return imap_utf8($mime_encoded_text);
} else {
return iconv_mime_decode($mime_encoded_text, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8");
return \Helper::iconvMimeDecode($mime_encoded_text);
}
}
// Accepts both header name with "-" and "_".
public static function getHeader($headers_str, $header)
{
// Quick check to same resources.
if (!stristr($headers_str, $header)) {
return '';
}
$value = '';
$headers_str = $headers_str ?? '';
$header = strtolower($header);
$header = str_replace('-', '_', $header);
$headers = self::parseHeaders($headers_str);
if (!$headers) {
return;
}
$value = null;
if (property_exists($headers, $header)) {
$value = $headers->$header;
} else {
$header_dashed = str_replace('_', '-', $header);
// Quick check to save resources.
if (!stristr($headers_str, $header_dashed)) {
return '';
}
// For single-line headers try to get haeder by using regular expression.
if ($header == 'message_id') {
if (preg_match('/^'.preg_quote($header_dashed).'\s*:((?:[^\r\n]|\r?\n[ \t])*)/im', $headers_str ?? '', $m)
&& !empty($m[1])
) {
$value = $m[1];
}
}
if (!$value) {
$headers = self::parseHeaders($headers_str);
if (!$headers) {
return;
}
$value = null;
if (property_exists($headers, $header)) {
$value = $headers->$header;
} else {
return '';
}
}
// Sanitize.
switch ($header) {
case 'message_id':
$value = str_replace(['<', '>'], '', $value);
// Message-ID can't contain spaces, so it's safe to remove them
// along with the line folding.
$value = str_replace(['<', '>', "\r", "\n", "\t", ' '], '', $value);
break;
}
@@ -761,28 +808,45 @@ class Mail
public static function getMailboxClient($mailbox)
{
$oauth = $mailbox->oauthEnabled();
/*$new_library = config('app.new_fetching_library');
if (!$new_library) {
// Old.
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 {*/
// New
// 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($mailbox->oauthGetParam('provider'), [
'client_id' => $mailbox->getInOauthClientId(),
'client_secret' => $mailbox->in_password,
'refresh_token' => $mailbox->oauthGetParam('r_token'),
]);
if (!empty($token_data['a_token'])) {
// In Google Workspace new refresh token is not returned after refreshing the token.
if (empty($token_data['r_token'])) {
$token_data['r_token'] = $mailbox->oauthGetParam('r_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);
}
}
}
// Set config.
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,
'username' => $mailbox->getInOauthUsername(),
'password' => $mailbox->oauthGetParam('a_token'),
'protocol' => $mailbox->getInProtocolName(),
'authentication' => 'oauth',
@@ -802,38 +866,15 @@ class Mail
'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);
@@ -854,6 +895,11 @@ class Mail
return 'fs-'.$hash.'@'.preg_replace("/.*@/", '', $email_address);
}
public static function isGeneratedMessageId($message_id)
{
return preg_match("#^fs\-[a-z0-9]+@#", $message_id ?? '');
}
/**
* Fetch IMAP message by Message-ID.
*/
@@ -893,9 +939,8 @@ class Mail
// 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));
$query->since($message_date->copy()->subDays(7));
$query->before($message_date->copy()->addDays(7));
}
if ($no_charset) {
@@ -915,8 +960,8 @@ class Mail
//$query = $folder->query()->text('<'.$message_id.'>')->leaveUnread()->limit(1)->setCharset(null);
$query = $folder->query()->whereMessageId('"<'.$search_message_id.'>"')->leaveUnread()->limit(1)->setCharset(null);
if ($message_date) {
$query->since($message_date->subDays(7));
$query->before($message_date->addDays(14));
$query->since($message_date->copy()->subDays(7));
$query->before($message_date->copy()->addDays(7));
}
$messages = $query->get();
$no_charset = true;
@@ -931,6 +976,100 @@ class Mail
}
}
return self::findMessageByHeaders($mailbox, $client, $imap_folders, $message_id, $message_date);
}
/**
* Find an IMAP message by Message-ID without using IMAP search.
*
* Some IMAP servers don't return search results by Message-ID: imap.yandex.ru
* for example responds with "NO [UNAVAILABLE] UID SEARCH Backend error".
* For those we fetch the headers of the messages received around the date of
* the message and compare Message-IDs locally.
*/
public static function findMessageByHeaders($mailbox, $client, $imap_folders, $message_id, $message_date)
{
// Without a date we would have to scan the whole mailbox.
if (!$message_date) {
return null;
}
$limit = (int) \Eventy::filter('mail.fetch_message.scan_limit', 500, $mailbox);
if ($limit <= 0) {
return null;
}
$message_id = trim($message_id);
// IMAP searches by INTERNALDATE which may differ from the date in the
// headers, so if the message is not found on its own date, try the
// neighbouring days too.
$date_ranges = [
[$message_date->copy(), $message_date->copy()->addDay()],
[$message_date->copy()->subDay(), $message_date->copy()->addDays(2)],
];
$connection = $client->getConnection();
foreach ($imap_folders as $folder_name) {
try {
$folder = self::getImapFolder($client, $folder_name);
if (!$folder) {
continue;
}
$limit_reached = false;
foreach ($date_ranges as $date_range) {
$client->openFolder($folder->path);
$uids = array_values($connection->search([
'SINCE "'.$date_range[0]->format('d-M-Y').'"'
.' BEFORE "'.$date_range[1]->format('d-M-Y').'"'
]));
// Calling the protocol directly here, so the server response
// has to be checked here as well.
if (!count($uids)
&& method_exists($connection, 'getLastError')
&& $connection->getLastError()
) {
\Log::error('('.$mailbox->name.') Show Original - IMAP search rejected by the server'
.' in "'.$folder_name.'"; server response: '.$connection->getLastError());
}
if (count($uids) > $limit) {
$uids = array_slice($uids, 0, $limit);
$limit_reached = true;
}
// Fetching the headers only and in chunks, to stop as soon as
// the message is found. Note that fetching RFC822.HEADER does
// not set the \Seen flag.
foreach (array_chunk($uids, self::SCAN_CHUNK_SIZE) as $uids_chunk) {
$headers = $connection->headers($uids_chunk);
foreach ($headers as $uid => $header) {
if (self::getHeader($header, 'Message-ID') != $message_id) {
continue;
}
return $folder->query()->leaveUnread()->getMessageByUid($uid);
}
}
}
if ($limit_reached) {
\Log::error('('.$mailbox->name.') MailHelper::findMessageByHeaders(): the number of messages to scan in "'
.$folder_name.'" exceeds the limit ('.$limit.'), message not found: '.$message_id);
}
} catch (\Exception $e) {
\Helper::logException($e, '('.$mailbox->name.') Could not find specific message by Message-ID in headers:');
}
}
return null;
}
@@ -939,6 +1078,7 @@ class Mail
$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 = [
@@ -946,10 +1086,24 @@ class Mail
'response_type' => 'code',
'approval_prompt' => 'auto',
'redirect_uri' => route('mailboxes.oauth_callback'),
//'state' => // passed in $params
];
$args = array_merge($args, $params);
$url = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?'.http_build_query($args);
break;
case self::OAUTH_PROVIDER_GOOGLE:
$args = [
'scope' => 'https://mail.google.com/',
'response_type' => 'code',
'prompt' => 'consent', // Without this there will be no refresh_token in response
'redirect_uri' => route('mailboxes.oauth_callback'),
'access_type' => 'offline', // Without this there will be no refresh_token in response
//'state' => // passed in $params
];
$args = array_merge($args, $params);
$url = 'https://accounts.google.com/o/oauth2/v2/auth?'.http_build_query($args);
break;
}
return $url;
@@ -961,6 +1115,7 @@ class Mail
$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',
@@ -1023,20 +1178,103 @@ class Mail
$token_data['error'] = 'Response code: '.curl_getinfo($curl, CURLINFO_HTTP_CODE);
}
}
curl_close($curl);
if (PHP_VERSION_ID < 80000) {
curl_close($curl);
}
break;
case self::OAUTH_PROVIDER_GOOGLE:
$post_params = [
//'scope' => 'https://mail.google.com/', // Just in case
"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';
}
$full_url = "https://oauth2.googleapis.com/token";
$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);
// {
// "access_token": "...",
// "refresh_token": "...",
// "expires_in": 3598,
// "scope": "https://mail.google.com/",
// "token_type": "Bearer"
// }
if (!empty($result['access_token'])) {
$token_data['provider'] = self::OAUTH_PROVIDER_GOOGLE;
$token_data['a_token'] = $result['access_token'];
if ($post_params['grant_type'] != 'refresh_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);
}
}
if (PHP_VERSION_ID < 80000) {
curl_close($curl);
}
break;
}
return $token_data;
}
public static function oauthDisconnect($provider_code, $redirect_uri)
public static function oauthDisconnect($provider_code, $redirect_uri, $mailbox)
{
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;
case self::OAUTH_PROVIDER_GOOGLE:
// This does not work.
// $post_params = [
// 'token' => $mailbox->oauthGetParam('a_token')
// ];
// $full_url = "https://oauth2.googleapis.com/revoke";
// $curl = curl_init($full_url);
// curl_setopt($curl, CURLOPT_POST, true);
// 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);
// $http_status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
// if ($http_status != 200) {
// \Log::error('[Google Workspace OAuth] Error revoking token on logout: HTTPS Status Code - '.$http_status.'; '.json_encode($response));
// }
return redirect()->away($redirect_uri);
}
}
@@ -1069,7 +1307,7 @@ class Mail
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");
$folder_name = mb_convert_encoding($folder_name, "UTF7-IMAP", "UTF-8");
if (method_exists($client, 'getFolderByPath')) {
return $client->getFolderByPath($folder_name);
@@ -1115,7 +1353,7 @@ class Mail
// Only one type of encoding should be used.
preg_match_all("/(=\?[^\?]+\?[BQ]\?)([^\?]+)(\?=)/i", $subject, $m);
$encodings = $m[1] ?? [];
array_walk($encodings, function($value) {
array_walk($encodings, function ($value) {
$value = strtolower($value);
});
$one_encoding = count(array_unique($encodings)) == 1;
@@ -1165,7 +1403,7 @@ class Mail
// 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");
$subject_decoded = \Helper::iconvMimeDecode($subject);
// Sometimes iconv_mime_decode() can't decode some parts of the subject:
// =?iso-2022-jp?B?IBskQiFaSEcyPDpuQC4wTU1qIVs3Mkp2JSIlLyU3JSItahsoQg==?=
@@ -1189,7 +1427,8 @@ class Mail
return $subject_decoded;
}
public static function isNotYetFullyDecoded($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 = ['<27>'];
@@ -1198,7 +1437,8 @@ class Mail
|| \Str::contains($subject_decoded, $invalid_utf_symbols);
}
public static function getHashedReplySeparator($message_id) {
public static function getHashedReplySeparator($message_id)
{
$separator = \MailHelper::REPLY_SEPARATOR_HTML;
if ($message_id) {
@@ -1208,15 +1448,62 @@ class Mail
return $separator;
}
/**
* Make images fit into the email container so that wide images
* do not stretch the layout.
* - Scale down width/height attributes (Outlook ignores CSS max-width).
* - Add "max-width:100%; height:auto" inline styles.
*/
public static function fitImages($html, $max_width = 0)
{
if (!$html || stripos($html, '<img') === false) {
return $html;
}
return preg_replace_callback('#<img\b[^>]*>#i', function ($m) use ($max_width) {
$tag = $m[0];
// Scale down dimensions preserving aspect ratio.
if ($max_width
&& preg_match('#\swidth\s*=\s*["\']?(\d+)#i', $tag, $w)
&& (int)$w[1] > $max_width
) {
$width = (int)$w[1];
$tag = preg_replace('#(\swidth\s*=\s*["\']?)\d+#i', '${1}'.$max_width, $tag, 1);
if (preg_match('#\sheight\s*=\s*["\']?(\d+)#i', $tag, $h)) {
$height = (int)round((int)$h[1] * $max_width / $width);
$tag = preg_replace('#(\sheight\s*=\s*["\']?)\d+#i', '${1}'.$height, $tag, 1);
}
}
$fit_style = 'max-width:100%;height:auto;';
if (preg_match('#\sstyle\s*=\s*(["\'])(.*?)\1#is', $tag, $s)) {
// Remove existing max-width and height declarations.
$style = preg_replace('#(^|;)\s*(max-width|height)\s*:[^;]*#i', '$1', $s[2]);
$style = trim(preg_replace('#;(\s*;)+#', ';', $style), " \t\r\n;");
$style = ($style !== '' ? $style.';' : '').$fit_style;
$tag = str_replace($s[0], ' style='.$s[1].$style.$s[1], $tag);
} else {
$self_closing = preg_match('#/\s*>$#', $tag);
$tag = preg_replace('#\s*/?\s*>$#', '', $tag).' style="'.$fit_style.'"'.($self_closing ? ' />' : '>');
}
return $tag;
}, $html) ?: $html;
}
// Sanitize status message - remove SMTP username and password.
public static function sanitizeSmtpStatusMessage($status_message) {
public static function sanitizeSmtpStatusMessage($status_message)
{
$status_message = preg_replace('#(username ")[^"]+(")#', '$1***$2', $status_message ?? '');
$status_message = preg_replace("#(Swift_Transport_Esmtp_Auth_LoginAuthenticator\->authenticate\(Object\(Swift_SmtpTransport\), ')[^\']+(', ')[^\']+('\))#", '$1***$2***$3', $status_message ?? '');
return $status_message;
}
public static function parseEml($content, $mailbox) {
public static function parseEml($content, $mailbox)
{
if (!str_contains($content, "\r\n")){
$content = str_replace("\n", "\r\n", $content);
}
@@ -1253,6 +1540,38 @@ class Mail
return $string;
}
/**
* Remove quotes surrounding a display name in an email address.
*
* In `From: "Tatiana Ivanova" <ti@example.org>` the quotes are just
* a delimiter of the quoted-string, they are not a part of the name itself:
* https://datatracker.ietf.org/doc/html/rfc5322#section-3.2.4
*/
public static function unquotePersonalName($name)
{
$name = trim((string)$name);
if (strlen($name) < 2 || !\Str::startsWith($name, '"') || !\Str::endsWith($name, '"')) {
return $name;
}
$unquoted = substr($name, 1, -1);
// Make sure that it's a single quoted-string and not something like
// `"Foo" bar "Baz"` - all quotes inside have to be escaped.
if (!preg_match('/^(?:[^"\\\\]|\\\\.)*$/s', $unquoted)) {
return $name;
}
// Unescape quoted-pairs: \" => " and \\ => \
return trim(preg_replace('/\\\\(.)/s', '$1', $unquoted));
}
public static function isFsMessageId($message_id)
{
return preg_match('/^('.implode('|', self::$all_message_id_prefixes).')/i', $message_id);
}
// public static function oauthGetProvider($provider_code, $params)
// {
// $provider = null;

View File

@@ -10,10 +10,10 @@ class SwiftGetSmtpQueueId implements \Swift_Events_ResponseListener
{
$response_text = $evt->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]);
}
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]);
}
}
}
}

View File

@@ -40,12 +40,14 @@ class WpApi
return $client->request('POST', $url, \Helper::setGuzzleDefaultOptions([
'connect_timeout' => 10,
'form_params' => $params,
'allow_redirects' => ['strict' => true],
]));
} else {
$params['v'] = config('app.version');
return $client->request('GET', $url, \Helper::setGuzzleDefaultOptions([
'connect_timeout' => 10,
'query' => $params,
'allow_redirects' => ['strict' => true],
]));
}
}

View File

@@ -35,6 +35,11 @@ class Module extends Model
return self::$modules;
}
public static function clearModulesCache()
{
self::$modules = null;
}
public static function isActive($alias)
{
$module = self::getByAlias($alias);
@@ -50,7 +55,21 @@ class Module extends Model
$module = self::getByAliasOrCreate($alias);
$module->active = $active;
if ($save) {
$module->save();
try {
$module->save();
} catch (\Exception $e) {
if (strstr($e->getMessage(), 'Integrity constraint violation')) {
// SQLSTATE[23000]: Integrity constraint violation.
self::clearModulesCache();
$module = self::getByAliasOrCreate($alias);
if ($module) {
$module->active = $active;
$module->save();
}
} else {
throw $e;
}
}
}
return true;
@@ -134,6 +153,30 @@ class Module extends Model
$module->save();
}
/**
* Automatically encrypt license key.
*/
public function setLicenseAttribute($value)
{
if ($value != '') {
$this->attributes['license'] = \Helper::encrypt($value);
} else {
$this->attributes['license'] = '';
}
}
/**
* Automatically decrypt license key.
*/
public function getLicenseAttribute($value)
{
if (!$value) {
return '';
}
return \Helper::decrypt($value) ?: $value;
}
public static function normalizeAlias($alias)
{
return trim(strtolower($alias));
@@ -239,10 +282,7 @@ class Module extends Model
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) != ']'
) {
if (self::isThirdParty($module_data) && mb_substr(trim($module_data['name']), -1) != ']') {
$module_data['name'] = $module_data['name'].' ['.__('Third-Party').']';
}
return $module_data;
@@ -250,7 +290,7 @@ class Module extends Model
public static function isThirdParty($module_data)
{
if (\App\Module::isOfficial($module_data['authorUrl'])
if (\App\Module::isOfficial($module_data['detailsUrl'])
&& $module_data['author'] != 'FreeScout'
) {
return true;

View File

@@ -68,16 +68,32 @@ class BroadcastNotification extends Notification implements ShouldQueue
{
$data = [];
$user = auth()->user();
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);
$mailbox = ($thread && $thread->conversation) ? $thread->conversation->mailbox : null;
if (empty($thread)) {
if (empty($thread) || !$user || !$mailbox || !$user->can('viewCached', $mailbox)) {
return $data;
}
$conversation = $thread->conversation;
// Play audio notification if this is Chat conversation.
//$now = time();
if ($conversation->isChat() /*&& $conversation->last_reply_at && $conversation->last_reply_at->timestamp < ($now - 10*60)*/) {
$data['audio'] = [
'thread_id' => $thread->id,
];
}
if (empty($payload->mediums)) {
return $data;
}
@@ -90,7 +106,7 @@ class BroadcastNotification extends Notification implements ShouldQueue
// 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)
$last_thread = Thread::where('conversation_id', $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'])
@@ -107,7 +123,7 @@ class BroadcastNotification extends Notification implements ShouldQueue
$web_notifications_info['created_at'] = \Carbon\Carbon::now();
// ['notification']->read_at
// ['notification']->id
$web_notifications_info['conversation'] = $thread->conversation;
$web_notifications_info['conversation'] = $conversation;
$web_notifications_info['thread'] = $thread;
$web_notifications_info['last_thread_body'] = $last_thread_body;
@@ -118,8 +134,8 @@ class BroadcastNotification extends Notification implements ShouldQueue
// 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]);
$data['browser']['text'] = \Helper::stripTags($thread->getActionDescription($conversation->number));
$data['browser']['url'] = $conversation->url(null, $thread->id, ['mark_as_read' => $db_notification->id]);
}
return $data;

View File

@@ -19,15 +19,18 @@ class WebsiteNotification extends Notification implements ShouldQueue
public $thread;
public $data;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct($conversation, $thread)
public function __construct($conversation, $thread, $data = [])
{
$this->conversation = $conversation;
$this->thread = $thread;
$this->data = $data;
}
/**
@@ -54,13 +57,14 @@ class WebsiteNotification extends Notification implements ShouldQueue
return [
'thread_id' => $this->thread->id,
'conversation_id' => $this->conversation->id,
'data' => $this->data,
];
}
/**
* Fetch data from DB for notifications list to display it.
*/
public static function fetchNotificationsData($notifications)
public static function fetchNotificationsData($notifications, $auth_user = null)
{
$data = [];
@@ -141,6 +145,13 @@ class WebsiteNotification extends Notification implements ShouldQueue
continue;
}
// Check access to the conversation
// and reset $last_thread_body if there is no access anymore.
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-6545-8c3f-pp6w
if (!$auth_user->can('view', $conversation)) {
$last_thread_body = '…';
}
$data[] = [
'notification' => $notification,
'created_at' => $notification->created_at,
@@ -150,6 +161,7 @@ class WebsiteNotification extends Notification implements ShouldQueue
'user' => $user,
'created_by_user' => $created_by_user,
'created_by_customer' => $created_by_customer,
'data' => $notification->data['data'] ?? [],
];
}

View File

@@ -16,6 +16,8 @@ class ConversationObserver
if ($conversation->source_via == Conversation::PERSON_USER) {
$conversation->read_by_user = true;
}
$conversation->subject = mb_substr($conversation->subject ?? '', 0, Conversation::SUBJECT_MAXLENGTH);
}
/**
@@ -29,6 +31,17 @@ class ConversationObserver
//$conversation->mailbox->updateFoldersCounters();
}
/**
* On before updating.
*
* @param Conversation $conversation
*/
public function updating(Conversation $conversation)
{
// https://github.com/freescout-help-desk/freescout/issues/5201
$conversation->subject = mb_substr($conversation->subject ?? '', 0, Conversation::SUBJECT_MAXLENGTH);
}
/**
* On conversation delete.
*

View File

@@ -6,6 +6,17 @@ use Illuminate\Notifications\DatabaseNotification;
class DatabaseNotificationObserver
{
/**
* Populate "conversation_id" column.
*/
public function creating(DatabaseNotification $notification)
{
// $notification->data is already cast to array by the base model.
if (!empty($notification->data['conversation_id'])) {
$notification->conversation_id = $notification->data['conversation_id'];
}
}
/**
* Notifications DB record created.
*

View File

@@ -6,6 +6,17 @@ use App\Email;
class EmailObserver
{
/**
* On create before saving.
*
* @param Email $email
*/
public function creating(Email $email)
{
// https://github.com/freescout-help-desk/freescout/issues/5106
$email->email = Email::sanitizeLength($email->email);
}
/**
* Email created.
*

View File

@@ -33,10 +33,16 @@ class MailboxObserver
*/
public function deleting(Mailbox $mailbox)
{
// Same things are deleted in Mailbox->deleteMailbox().
$mailbox->users()->delete();
$mailbox->conversations()->delete();
$mailbox->folders()->delete();
\Eventy::action('mailbox.before_delete', $mailbox);
}
public function deleted(Mailbox $mailbox)
{
\Eventy::action('mailbox.deleted', $mailbox);
}
}

View File

@@ -25,8 +25,8 @@ class ThreadObserver
$use_mail_date_on_fetching = config('app.use_mail_date_on_fetching');
if ($use_mail_date_on_fetching) {
$now = $thread->created_at;
}else{
$now = $thread->created_at->format('Y-m-d H:i:s');
} else {
$now = date('Y-m-d H:i:s');
}
@@ -38,12 +38,18 @@ class ThreadObserver
}
if ((in_array($thread->type, [Thread::TYPE_CUSTOMER, Thread::TYPE_MESSAGE])
|| ($conversation->isPhone() && in_array($thread->type, [Thread::TYPE_NOTE])))
|| ($conversation->isPhone() && in_array($thread->type, [Thread::TYPE_NOTE]))
// https://github.com/freescout-help-desk/freescout/issues/5105
// This is not a normal situtaion: email conversation containing just one Note thread.
|| ($conversation->threads_count == 0 && $thread->type == Thread::TYPE_NOTE)
)
&& $thread->state == Thread::STATE_PUBLISHED
) {
// $conversation->cc = $thread->cc;
// $conversation->bcc = $thread->bcc;
$conversation->last_reply_at = $now;
$conversation->setLastReplyAt($now, $thread->source_via);
// "last_reply_from" may be already set at this stage (by FetchEmails for example).
$conversation->last_reply_from = $thread->source_via;
}
if ($conversation->source_via == Conversation::PERSON_CUSTOMER) {

View File

@@ -1,6 +1,6 @@
<?php
/**
* todo: implement caching by saving all options in one cache variable on register_shutdown_function.
* Todo: implement caching by saving all options in one cache variable on register_shutdown_function.
*/
namespace App;
@@ -51,7 +51,8 @@ class Option extends Model
$serialized_value = self::maybeSerialize($value);
$option = self::firstOrCreate(
['name' => $name], ['value' => $serialized_value]
['name' => $name],
['value' => $serialized_value]
);
$old_value = $option['value'];
@@ -229,7 +230,7 @@ class Option extends Model
// Options are not seralized using serialize() anymore.
if (self::isSerialized($original)) {
try {
$original = unserialize($original);
$original = unserialize($original, ['allowed_classes' => false]);
} catch (\Exception $e) {
// Do nothing
}

View File

@@ -25,6 +25,10 @@ class ConversationPolicy
return true;
} else {
if ($conversation->userHasAccessToMailbox($user->id)) {
// Regular users can not see Archived mailboxes.
if ($conversation->isMailboxArchived()) {
return false;
}
// Maybe user can see only assigned conversations.
return $this->checkIsOnlyAssigned($conversation, $user);
} else {
@@ -46,6 +50,10 @@ class ConversationPolicy
return true;
} else {
if ($conversation->mailbox->users_cached->contains($user)) {
// Regular users can not see Archived mailboxes.
if ($conversation->isMailboxArchived()) {
return false;
}
// Maybe user can see only assigned conversations.
return $this->checkIsOnlyAssigned($conversation, $user);
} else {
@@ -64,16 +72,7 @@ class ConversationPolicy
*/
public function update(User $user, Conversation $conversation)
{
if ($user->isAdmin()) {
return true;
} else {
if ($conversation->userHasAccessToMailbox($user->id)) {
// Maybe user can see only assigned conversations.
return $this->checkIsOnlyAssigned($conversation, $user);
} else {
return false;
}
}
return $this->view($user, $conversation);
}
/**
@@ -91,6 +90,10 @@ class ConversationPolicy
return true;
}
if ($conversation->userHasAccessToMailbox($user->id)) {
// Regular users can not see Archived mailboxes.
if ($conversation->isMailboxArchived()) {
return false;
}
// Maybe user can see only assigned conversations.
return $this->checkIsOnlyAssigned($conversation, $user);
} else {
@@ -119,7 +122,7 @@ class ConversationPolicy
public function checkIsOnlyAssigned($conversation, $user)
{
// Maybe user can see only assigned conversations.
if (!\Eventy::filter('conversation.is_user_assignee', $conversation->user_id == $user->id, $conversation, $user->id)
if (!$conversation->isAssignedToUser($user)
&& $conversation->created_by_user_id != $user->id
&& $user->canSeeOnlyAssignedConversations()
) {

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Conversation;
use App\Customer;
use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class CustomerPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view the model.
*
* @param \App\User $user
* @param \App\User $model
*
* @return mixed
*/
public function view(User $user, Customer $customer)
{
if (!$customer) {
return false;
}
$limited_visibility = config('app.limit_user_customer_visibility') && !$user->isAdmin();
if ($limited_visibility) {
$mailbox_ids = $user->mailboxesIdsCanView();
$accesible = Conversation::where('customer_id', $customer->id)
->whereIn('conversations.mailbox_id', $mailbox_ids)
->exists();
if (!$accesible) {
return false;
}
}
return true;
}
}

View File

@@ -38,7 +38,7 @@ class MailboxPolicy
if ($user->isAdmin()) {
return true;
} else {
if ($mailbox->users->contains($user)) {
if ($mailbox->users->contains($user) && !$mailbox->isArchived()) {
return true;
} else {
return false;
@@ -59,7 +59,7 @@ class MailboxPolicy
return true;
} else {
// Use cached users for Realtime events
if ($mailbox->users_cached->contains($user)) {
if ($mailbox->users_cached->contains($user) && !$mailbox->isArchived()) {
return true;
} else {
return false;
@@ -90,7 +90,8 @@ class MailboxPolicy
*/
public function update(User $user, Mailbox $mailbox)
{
if ($user->isAdmin() || $user->canManageMailbox($mailbox->id)) {
// canManageMailbox() checks if mailbox is archived.
if ($user->isAdmin() || $user->canManageMailbox($mailbox)) {
return true;
} else {
return false;
@@ -107,10 +108,16 @@ class MailboxPolicy
*/
public function updateAutoReply(User $user, Mailbox $mailbox)
{
if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_AUTO_REPLIES)) {
if ($user->isAdmin()) {
return true;
} else {
return false;
if ($user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_AUTO_REPLIES)
&& !$mailbox->isArchived()
) {
return true;
} else {
return false;
}
}
}
@@ -124,10 +131,16 @@ class MailboxPolicy
*/
public function updatePermissions(User $user, Mailbox $mailbox)
{
if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_PERMISSIONS)) {
if ($user->isAdmin()) {
return true;
} else {
return false;
if ($user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_PERMISSIONS)
&& !$mailbox->isArchived()
) {
return true;
} else {
return false;
}
}
}
@@ -141,10 +154,16 @@ class MailboxPolicy
*/
public function updateSettings(User $user, Mailbox $mailbox)
{
if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_EDIT)) {
if ($user->isAdmin()) {
return true;
} else {
return false;
if ($user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_EDIT)
&& !$mailbox->isArchived()
) {
return true;
} else {
return false;
}
}
}
@@ -158,10 +177,16 @@ class MailboxPolicy
*/
public function updateEmailSignature(User $user, Mailbox $mailbox)
{
if ($user->isAdmin() || $user->hasManageMailboxPermission($mailbox->id, Mailbox::ACCESS_PERM_SIGNATURE)) {
if ($user->isAdmin()) {
return true;
} else {
return false;
if ($user->hasManageMailboxPermission($mailbox->id, [Mailbox::ACCESS_PERM_SIGNATURE, Mailbox::ACCESS_PERM_EDIT])
&& !$mailbox->isArchived()
) {
return true;
} else {
return false;
}
}
}

View File

@@ -20,10 +20,24 @@ class ThreadPolicy
*/
public function edit(User $user, Thread $thread)
{
if (($thread->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]))
if ((
// Thread created by user.
$thread->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->conversation
&& $thread->conversation->userHasAccessToMailbox($user->id)
&& $this->checkIsOnlyAssigned($thread->conversation, $user)
)
)) || (
// Thread created by customer.
$thread->created_by_customer_id
&& in_array($thread->type, [Thread::TYPE_CUSTOMER])
&& $thread->conversation
&& ($user->isAdmin() || ($thread->conversation->userHasAccessToMailbox($user->id) && $this->checkIsOnlyAssigned($thread->conversation, $user)))
)
) {
return true;
} else {
@@ -33,10 +47,32 @@ class ThreadPolicy
public function delete(User $user, Thread $thread)
{
// Admin also can delete only own notes.
if ($thread->created_by_user_id == $user->id) {
return true;
if ($thread->conversation
&& (!$thread->conversation->userHasAccessToMailbox($user->id) || !$this->checkIsOnlyAssigned($thread->conversation, $user))
) {
return false;
} else {
return true;
}
} else {
return false;
}
}
// If user can see only assigned conversations,
// check if he/she has access to the conversation.
public function checkIsOnlyAssigned($conversation, $user)
{
// Maybe user can see only assigned conversations.
if (!\Eventy::filter('conversation.is_user_assignee', $conversation->user_id == $user->id, $conversation, $user->id)
&& $conversation->created_by_user_id != $user->id
&& $user->canSeeOnlyAssignedConversations()
) {
return false;
} else {
return true;
}
}
}

View File

@@ -54,7 +54,28 @@ class UserPolicy
{
if ($user->isAdmin()
|| $user->id == $model->id
|| $user->hasPermission(User::PERM_EDIT_USERS)
// Do not allow regular users edit admin profiles.
|| ($user->hasPermission(User::PERM_EDIT_USERS) && !$model->isAdmin())
) {
return true;
} else {
return false;
}
}
/**
* Determine whether the user can update model's notification settings.
*
* @param \App\User $user
* @param \App\User $model
*
* @return mixed
*/
public function updateNotifications(User $user, User $model)
{
if ($user->isAdmin()
|| $user->id == $model->id
|| ($user->hasPermission(User::PERM_EDIT_USERS) && !$model->isAdmin())
) {
return true;
} else {

View File

@@ -30,6 +30,28 @@ class AppServiceProvider extends ServiceProvider
\App\Attachment::observe(\App\Observers\AttachmentObserver::class);
\App\Follower::observe(\App\Observers\FollowerObserver::class);
\Illuminate\Notifications\DatabaseNotification::observe(\App\Observers\DatabaseNotificationObserver::class);
\Validator::extend('safehost', function ($attribute, $value, $parameters, $validator) {
if (!$value) {
return true;
}
$msg = '';
try {
$url = $value;
if (!preg_match("#^https?://#", $value)) {
$url = 'https://'.$url;
}
\Helper::sanitizeRemoteUrl($url, true);
} catch (\Exception $e) {
$msg = $e->getMessage();
}
if ($msg) {
$validator->errors()->add($attribute, $msg);
return false;
}
return true;
});
}
/**

View File

@@ -17,6 +17,7 @@ class AuthServiceProvider extends ServiceProvider
'App\Folder' => 'App\Policies\FolderPolicy',
'App\Conversation' => 'App\Policies\ConversationPolicy',
'App\Thread' => 'App\Policies\ThreadPolicy',
'App\Customer' => 'App\Policies\CustomerPolicy',
];
/**

View File

@@ -94,6 +94,10 @@ class EventServiceProvider extends ServiceProvider
'App\Listeners\SendNotificationToUsers',
'App\Listeners\RefreshConversations',
],
'App\Events\UserMovedConversation' => [
'App\Listeners\SendNotificationToUsers',
],
];
/**

View File

@@ -50,6 +50,8 @@ class PolycastServiceProvider extends ServiceProvider
// establish connection and send current time
$this->app['router']->post('polycast/connect', function (Request $request) {
\Broadcast::auth($request);
return ['status' => 'success', 'time' => Carbon::now()->toDateTimeString()];
});
@@ -62,7 +64,17 @@ class PolycastServiceProvider extends ServiceProvider
$channels = $request->get('channels', []);
// $request->channels must contain a list of channels.
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-7x4c-p73f-67x5
if (empty($channels)) {
\Helper::denyAccess();
}
foreach ($channels as $channel => $events) {
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-gh3r-jh6q-wrvj
$channel = \Helper::sqlEscapeLike($channel);
foreach ($events as $event) {
// No need to add index to DB for this query.
$query->orWhere(function ($query) use ($channel, $event, $request) {
@@ -81,14 +93,18 @@ class PolycastServiceProvider extends ServiceProvider
$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
// Add extra data to the payload - used in RealtimeBroadcastNotificationCreated.
// 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.
// If user is not allowed to access this event, payload will be set to empty array.
$item->payload = $event_class::processPayload($item->payload);
if (!$item->payload) {
$item->data = [];
}
}
$item->delay = $requested->diffInSeconds($created);

View File

@@ -38,6 +38,7 @@ class RouteServiceProvider extends ServiceProvider
//$this->mapApiRoutes();
$this->mapWebRoutes();
$this->mapOpenRoutes();
//
}
@@ -64,6 +65,21 @@ class RouteServiceProvider extends ServiceProvider
->group(base_path('routes/web.php'));
}
protected function mapOpenRoutes()
{
$subdirectory = \Helper::getSubdirectory();
if ($subdirectory) {
$route = Route::prefix($subdirectory)
->middleware('open');
} else {
$route = Route::middleware('open');
}
$route->namespace($this->namespace)
->group(base_path('routes/open.php'));
}
/**
* Define the "api" routes for the application.
*

View File

@@ -16,10 +16,12 @@ class Subscription extends Model
// Changing ticket status does not fire event
const EVENT_TYPE_NEW = 1;
const EVENT_TYPE_ASSIGNED = 2;
// This event type exists but not used anywhere for now.
const EVENT_TYPE_UPDATED = 3;
const EVENT_TYPE_CUSTOMER_REPLIED = 4;
const EVENT_TYPE_USER_REPLIED = 5;
const EVENT_TYPE_USER_ADDED_NOTE = 6;
const EVENT_TYPE_USER_MOVED = 7;
// Events
// Notify me when…
@@ -187,6 +189,7 @@ class Subscription extends Model
switch ($event_type) {
case self::EVENT_TYPE_NEW:
case self::EVENT_TYPE_USER_MOVED:
$events[] = self::EVENT_NEW_CONVERSATION;
break;
@@ -221,7 +224,7 @@ class Subscription extends Model
// Check if assigned user changed
$user_changed = false;
if ($event_type != self::EVENT_TYPE_ASSIGNED && $event_type != self::EVENT_TYPE_NEW) {
if (!in_array($event_type, [self::EVENT_TYPE_ASSIGNED, self::EVENT_TYPE_NEW, self::EVENT_TYPE_USER_MOVED])) {
if ($thread->type == Thread::TYPE_LINEITEM && $thread->action_type == Thread::ACTION_TYPE_USER_CHANGED) {
$user_changed = true;
} elseif ($prev_thread) {
@@ -243,7 +246,7 @@ class Subscription extends Model
$events = array_unique($events);
// Detect subscribed users
if (!$mailbox_user_ids) {
if ($mailbox_user_ids === null) {
$mailbox_user_ids = $conversation->mailbox->userIdsHavingAccess();
}
@@ -255,7 +258,7 @@ class Subscription extends Model
// Filter subscribers
foreach ($subscriptions as $i => $subscription) {
// Actions on conversation where user is assignee
// Actions on conversation where user must be 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))
) {
@@ -316,18 +319,30 @@ class Subscription extends Model
// 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 = [];
// Get mailbox users ids (from previous $notify record).
/*$mailbox_user_ids = null;
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'];
foreach ($notify[$medium] as $conversation_id => $notify_row) {
if ($notify_row['conversation']->mailbox_id == $event['conversation']->mailbox_id) {
$mailbox_user_ids = $notify_row['mailbox_user_ids'];
break 2;
}
}
}
}
if ($mailbox_user_ids === null) {
$mailbox_user_ids = $event['conversation']->mailbox->userIdsHavingAccess();
}*/
$mailbox_user_ids = $event['conversation']->mailbox->userIdsHavingAccess();
// When conversation is moved to another mailbox
// notify only users NOT having access to the original mailbox.
// https://github.com/freescout-help-desk/freescout/issues/5580
if ($event['event_type'] == self::EVENT_TYPE_USER_MOVED && !empty($event['extra_data']['from_mailbox'])) {
$original_mailbox_user_ids = $event['extra_data']['from_mailbox']->userIdsHavingAccess();
$mailbox_user_ids = array_diff($mailbox_user_ids, $original_mailbox_user_ids);
}
// Get users and threads from previous results to avoid repeated SQL queries.
$users = [];
@@ -364,7 +379,7 @@ class Subscription extends Model
'users' => array_unique(array_merge($users[$medium] ?? [], $medium_users_to_notify)),
'conversation' => $event['conversation'],
'threads' => $threads,
'mailbox_user_ids' => $mailbox_user_ids,
//'mailbox_user_ids' => $mailbox_user_ids,
];
}
}
@@ -384,15 +399,25 @@ class Subscription extends Model
|| !empty($notify[self::MEDIUM_BROWSER])
|| !empty($notify[self::MEDIUM_MENU])
) {
// https://github.com/freescout-help-desk/freescout/issues/5491
$notify_menu = [];
if (!empty($notify[self::MEDIUM_EMAIL])) {
$notify_menu = $notify[self::MEDIUM_EMAIL] ?? [];
} else {
$notify_menu = $notify[self::MEDIUM_BROWSER] ?? [];
}
foreach ($notify[self::MEDIUM_BROWSER] ?? [] as $conversation_id => $notify_info) {
if (empty($notify_menu[$conversation_id])) {
$notify_menu[$conversation_id] = $notify_info;
} else {
$notify_menu[$conversation_id]['users'] = collect($notify_menu[$conversation_id]['users'])
->merge($notify_info['users'])->unique('id')->values()->all();
}
}
$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);
}
}
@@ -440,6 +465,8 @@ class Subscription extends Model
*/
public static function chooseThread($threads)
{
//$last_meaningful_thread = null;
$actions_types = [
Thread::ACTION_TYPE_USER_CHANGED,
];
@@ -448,21 +475,31 @@ class Subscription extends Model
if ($thread->type == Thread::TYPE_LINEITEM && !in_array($thread->action_type, $actions_types)) {
continue;
} else {
// if (!$last_meaningful_thread) {
// $last_meaningful_thread = $thread;
// }
// // Make sure that user is not Deleted to filter out automatic messages.
// if (($thread->isUserMessage() || $thread->isNote()) && $thread->created_by_user && $thread->created_by_user->isDeleted()) {
// continue;
// }
return $thread;
}
}
//return $last_meaningful_thread ?? $threads[0];
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)
public static function registerEvent($event_type, $conversation, $caused_by_user_id, $extra_data = [], $process_now = false)
{
self::$occurred_events[] = [
'event_type' => $event_type,
'conversation' => $conversation,
'caused_by_user_id' => $caused_by_user_id,
'extra_data' => $extra_data,
];
// Automatically add EVENT_TYPE_UPDATED
@@ -471,6 +508,7 @@ class Subscription extends Model
'event_type' => self::EVENT_TYPE_UPDATED,
'conversation' => $conversation,
'caused_by_user_id' => $caused_by_user_id,
'extra_data' => $extra_data,
];
}
if ($process_now) {

View File

@@ -298,7 +298,7 @@ class Thread extends Model
*
* @return string
*/
public function getCleanBody($body = '')
public function getCleanBody($body = '', $escape_non_ascii_characters = false)
{
if (!$body) {
$body = $this->body;
@@ -316,15 +316,41 @@ class Thread extends Model
// Cut out "collapse" class as it hides elements.
$body = preg_replace("/(<[^<>\r\n]+class=([\"'][^\"']* |[\"']))(collapse|hidden)([\"' ])/", '$1$4', $body) ?: $body;
// Take care of MSO-comments.
// https://github.com/freescout-help-desk/freescout/issues/5068
// Step 1: Completely remove the MSO-specific blocks.
// The 's' flag allows '.' to match newlines, and 'i' makes it case-insensitive.
$body = preg_replace('/<!--\[if mso\]>.*?<!\[endif\]-->/is', '', $body);
// Step 2: Unwrap the standard HTML from the "not mso" comments.
// This reveals the standard <a> tag for the purifier.
$body = preg_replace('/<!--\[if !mso\]><!--\s*-->(.*?)<!--\s*<!\[endif\]-->/is', '$1', $body);
// Remove only the <!--[if !mso]><!--> and <!--<![endif]--> around the elements.
// https://github.com/freescout-helpdesk/freescout/pull/3865#issuecomment-1990758149
$body = preg_replace('/<!\-\-\[if [^>]+\]><!\-\->(.*?)<![ ]+\-\-<!\[endif\]\-\->/s', '$1', $body);
// Remove Outlook "downlevel-revealed" conditional comments (<![if !vml]>...<![endif]>),
// preserving the data inside. Browsers ignore them, but the purifier escapes them as text.
// Emails fetched before the fix have them already escaped (&lt;![if !vml]&gt;) in the DB.
$body = preg_replace('/(?:<|&lt;)!\[(?:if\s[^\]]*|endif)\](?:>|&gt;)/i', '', $body) ?: $body;
// https://github.com/freescout-helpdesk/freescout/issues/3894
// Remove <!--[if !mso]><!--> and <!--<![endif]--> comments, preserving the data inside
//$body = preg_replace('/(<!\-\-\[if [^>]+\]>|<!\[endif\]\-\->)/', '', $body);
return \Helper::purifyHtml($body);
// This option causes issues on some instances:
// https://github.com/freescout-help-desk/freescout/issues/5481#issuecomment-5113750894
if ($escape_non_ascii_characters) {
\Config::set('purifier.settings.default.Core.EscapeNonASCIICharacters', true);
}
$body = \Helper::purifyHtml($body);
if ($escape_non_ascii_characters) {
\Config::set('purifier.settings.default.Core.EscapeNonASCIICharacters', false);
}
return $body;
}
/**
@@ -343,23 +369,29 @@ class Thread extends Model
$body = \Helper::linkify($this->getCleanBody($body));
// Add target="_blank" to links.
// Add target="_blank" to external links.
$pattern = '/<a(.*?)?href=[\'"]?[\'"]?(.*?)?>/i';
$body = preg_replace_callback($pattern, function($m){
$body = preg_replace_callback($pattern, function ($m) {
$tpl = array_shift($m);
$href = isset($m[1]) ? $m[1] : null;
$href = trim(isset($m[1]) ? $m[1] : '');
if (preg_match('/target=[\'"]?(.*?)[\'"]?/i', $tpl)) {
return $tpl;
}
if (trim($href) && 0 === strpos($href, '#')) {
// Anchor links.
// Skip anchor link.
if ($href && 0 === strpos($href, '#')) {
return $tpl;
}
return preg_replace_callback('/href=/i', function($m2){
// Skip non-external links.
if (parse_url($href, PHP_URL_HOST) == \Helper::getDomain()) {
return $tpl;
}
// Add target.
return preg_replace_callback('/href=/i', function ($m2) {
return sprintf('target="_blank" %s', array_shift($m2));
}, $tpl);
@@ -378,6 +410,12 @@ class Thread extends Model
return $this->getCleanBody($this->body_original);
}
// Get "to" as array.
public function getTo($exclude_array = [])
{
return $this->getToArray($exclude_array);
}
/**
* Get thread recipients.
*
@@ -411,6 +449,12 @@ class Thread extends Model
return self::$types[$this->type];
}
// Get "Cc" as array.
public function getCc($exclude_array = [])
{
return $this->getCcArray($exclude_array);
}
/**
* Get thread CC recipients.
*
@@ -426,6 +470,12 @@ class Thread extends Model
return implode(', ', $this->getCcArray($exclude_array));
}
// Get "Bcc" as array.
public function getBcc($exclude_array = [])
{
return $this->getBccArray($exclude_array);
}
/**
* Get thread BCC recipients.
*
@@ -496,31 +546,13 @@ class Thread extends Model
*/
public static function statusCodeToName($status)
{
switch ($status) {
case self::STATUS_ACTIVE:
return __('Active');
break;
return Conversation::statusCodeToName($status);
}
case self::STATUS_PENDING:
return __('Pending');
break;
case self::STATUS_CLOSED:
return __('Closed');
break;
case self::STATUS_SPAM:
return __('Spam');
break;
case self::STATUS_NOCHANGE:
return __('Not changed');
break;
default:
return '';
break;
}
// Get normalized status.
public function getMainStatus()
{
return Conversation::toMainStatus($this->status);
}
/**
@@ -721,6 +753,9 @@ class Thread extends Model
$did_this = strip_tags($did_this);
}
if (!$person) {
$person = __('System');
}
if ($person) {
// This causes double escaping.
// if ($escape) {
@@ -739,10 +774,13 @@ class Thread extends Model
{
// Person
$person = $this->getActionPerson($conversation_number);
$did_this = $this->getActionText($conversation_number, false, false, null, '', $viewed_by_user);
if ($escape) {
$person = htmlspecialchars($person);
}
$did_this = $this->getActionText($conversation_number, false, false, null, $person, $viewed_by_user);
if ($escape) {
$did_this = htmlspecialchars($did_this);
}
@@ -968,6 +1006,9 @@ class Thread extends Model
if (!empty($data['action_type'])) {
$thread->action_type = $data['action_type'];
}
if (!empty($data['action_data'])) {
$thread->action_data = $data['action_data'];
}
if (!empty($data['meta'])) {
$thread->setMetas($data['meta']);
}
@@ -1115,7 +1156,8 @@ class Thread extends Model
$uploaded_file,
$embedded = false,
$thread->id,
$user_id ?? null
$user_id ?? null,
$data['attachments_upload_mode'] ?? \Helper::UPLOAD_MODE_DEFAULT
);
if ($attachment) {
@@ -1165,7 +1207,7 @@ class Thread extends Model
}
$conversation->status = $data['status'];
} else {
if ((int)$conversation->status != Conversation::STATUS_ACTIVE) {
if (!$conversation->isActive()) {
$update_folder = true;
}
// Reply from customer makes conversation active
@@ -1181,7 +1223,7 @@ class Thread extends Model
}
$conversation->status = $data['status'];
} else {
if ((int)$conversation->status != Conversation::STATUS_PENDING) {
if (!$conversation->isPending()) {
$update_folder = true;
}
// Reply from customer makes conversation active
@@ -1463,8 +1505,49 @@ class Thread extends Model
if (empty($this->headers)) {
return '';
}
preg_match("#From:\s*.*[^\s]*\s*<\s*(.*[^\s])\s*>\s*\n#", $this->headers ?? '', $m);
return $m[1] ?? '';
preg_match("#From:\s*(?:.*?<\s*)?([^\s<>]+)(?:\s*>)?\s*\n#i", $this->headers ?? '', $m);
return \Helper::sanitizeEmail($m[1] ?? '');
}
// Used to highlight sender when From is different from Reply-To.
public function getFromIfDifferentFromReplyTo($customer = null)
{
if (empty($this->headers) || !$this->isCustomerMessage() || !$customer) {
return '';
}
preg_match("#Reply\-To:\s*(?:.*?<\s*)?([^\s<>]+)(?:\s*>)?\s*\n#i", $this->headers ?? '', $m);
$reply_to = \Helper::sanitizeEmail($m[1] ?? '');
if (!$reply_to) {
return '';
}
$from = $this->getFromHeader();
if (!$from) {
return '';
}
if ($from == $reply_to) {
return '';
}
if (!$this->created_by_customer_id) {
return '';
}
if ($this->created_by_customer_id != $customer->id) {
if (!$this->created_by_customer) {
return '';
}
$customer = $this->created_by_customer;
}
if (!$customer->hasEmail($from)) {
return $from;
}
return '';
}
public function getMailDate()
@@ -1503,7 +1586,8 @@ class Thread extends Model
{
\Helper::setPcreBacktrackLimit();
$body = preg_replace_callback("#(<img[^<>]+src=[\"'])data:image/([^;]+);base64,([^\"']+)([\"'])#",
$body = preg_replace_callback(
"#(<img[^<>]+src=[\"'])data:image/([^;]+);base64,([^\"']+)([\"'])#",
function ($match) {
$attachment = null;
$data = base64_decode($match[3]);
@@ -1531,10 +1615,17 @@ class Thread extends Model
return $body;
}
// For customer messages: returns Message-ID from headers (if message_id is artificial).
// For user threads: hashed 'FS_reply_' prefixed message ID.
public function getMessageId($mailbox = null)
{
if ($this->isCustomerMessage() && $this->message_id) {
return $this->message_id;
if ($this->isCustomerMessage()) {
$message_id = $this->message_id;
if (\MailHelper::isGeneratedMessageId($message_id)) {
// Use Message-ID from customer message headers.
$message_id = $this->getHeader('Message-Id') ?: $message_id;
}
return $message_id;
}
if ($this->isUserMessage()) {
if (!$mailbox) {
@@ -1608,4 +1699,13 @@ class Thread extends Model
->where('payload', 'like', '{"displayName":"App\\\\\\\\Jobs\\\\\\\\SendReplyToCustomer"%{i:0;i:'.$this->id.';%')
->value('id');
}
// https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-qjr9-6v9q-3r72
public static function getOpenTrackingHash($thread, $conversation, $mailbox)
{
// https://github.com/freescout-help-desk/freescout/issues/5431
$hash = \Helper::hmacHash($thread->id.$thread->body.$thread->customer_id.$thread->created_at.$conversation->created_at.$mailbox->created_at);
return substr($hash, 0, 16);
}
}

View File

@@ -37,6 +37,8 @@ class User extends Authenticatable
const DEFAULT_TIMEZONE = 'UTC';
const INVITE_HASH_LENGTH = 60;
/**
* Roles.
*/
@@ -68,6 +70,11 @@ class User extends Authenticatable
const INVITE_STATE_SENT = 2;
const INVITE_STATE_NOT_INVITED = 3;
/**
* For how long invitation link is valid.
*/
const INVITE_TTL_DAYS = 7;
/**
* Time formats.
*/
@@ -75,7 +82,9 @@ class User extends Authenticatable
const TIME_FORMAT_24 = 2;
/**
* Global user permissions.
* User permissions.
* Core permissions have IDs below 100.
* Permissions in custom modules should use IDs above 1000.
*/
const PERM_DELETE_CONVERSATIONS = 1;
const PERM_EDIT_CONVERSATIONS = 2;
@@ -83,6 +92,7 @@ class User extends Authenticatable
const PERM_EDIT_TAGS = 4;
const PERM_EDIT_CUSTOM_FOLDERS = 5;
const PERM_EDIT_USERS = 10;
const PERM_ONLY_ASSIGNED_TICKETS = 11;
public static $user_permissions = [
self::PERM_DELETE_CONVERSATIONS,
@@ -117,7 +127,7 @@ class User extends Authenticatable
*
* @var [type]
*/
protected $fillable = ['role', 'status', 'first_name', 'last_name', 'email', 'password', 'timezone', 'photo_url', 'type', 'emails', 'job_title', 'phone', 'time_format', 'locale'];
protected $fillable = ['status', 'first_name', 'last_name', 'email', 'password', 'timezone', 'photo_url', 'type', 'emails', 'job_title', 'phone', 'time_format', 'locale'];
protected $casts = [
'permissions' => 'array',
@@ -126,7 +136,7 @@ class User extends Authenticatable
public function __construct(array $attributes = array())
{
$this->setRawAttributes(array_merge($this->attributes, array(
'timezone' => config('app.timezone') ?: User::DEFAULT_TIMEZONE
'timezone' => config('app.timezone') ?: User::DEFAULT_TIMEZONE,
)), true);
parent::__construct($attributes);
}
@@ -222,7 +232,7 @@ class User extends Authenticatable
*/
public function getFullName()
{
return \Eventy::filter('user.full_name', $this->first_name.' '.$this->last_name, $this);
return \Eventy::filter('user.full_name', trim($this->first_name.' '.$this->last_name), $this);
}
/**
@@ -252,6 +262,8 @@ class User extends Authenticatable
} else {
$mailboxes = $this->mailboxes;
}
// Exclude archived mailboxes.
$mailboxes = Mailbox::excludeArchived($mailboxes);
}
return $mailboxes->sortBy('name');
@@ -278,10 +290,17 @@ class User extends Authenticatable
});
}
if ($cache) {
return $query->rememberForever()->get();
$mailboxes = $query->rememberForever()->get();
} else {
return $query->get();
$mailboxes = $query->get();
}
if (!$this->isAdmin()) {
// Exclude archived mailboxes.
$mailboxes = Mailbox::excludeArchived($mailboxes);
}
return $mailboxes;
}
public function mailboxesSettings($cache = true)
@@ -316,12 +335,23 @@ class User extends Authenticatable
if ($this->isAdmin()) {
return Mailbox::pluck('id')->toArray();
} else {
return $this->mailboxes()->pluck('mailboxes.id')->toArray();
$mailboxes = $this->mailboxes()->pluck('mailboxes.meta', 'mailboxes.id');
// Exclude archived mailboxes.
return $mailboxes->filter(function ($meta) {
$meta = json_decode($meta, true);
if (!isset($meta[Mailbox::META_STATE])) {
return true;
}
return $meta[Mailbox::META_STATE] == Mailbox::STATE_ACTIVE;
})->keys()->toArray();
}
}
public function hasAccessToMailbox($mailbox_id)
{
if ($this->isAdmin()) {
return true;
}
$ids = $this->mailboxesIdsCanView();
return in_array($mailbox_id, $ids);
}
@@ -329,7 +359,8 @@ class User extends Authenticatable
/**
* Check to see if the user can manage any mailboxes
*/
public function hasManageMailboxAccess() {
public function hasManageMailboxAccess()
{
if ($this->isAdmin()) {
return true;
} else {
@@ -346,36 +377,47 @@ class User extends Authenticatable
/**
* Check to see if the user can manage a specific mailbox
*/
public function canManageMailbox($mailbox_id)
public function canManageMailbox($mailbox)
{
if ($this->isAdmin()) {
return true;
} else {
//$mailbox = $this->mailboxesCanViewWithSettings(true)->where('id', $mailbox_id)->first();
$mailbox = $this->mailboxesSettings()->where('mailbox_id', $mailbox_id)->first();
if ($mailbox && !empty(json_decode($mailbox->access ?? ''))) {
return true;
$mailbox_settings = $this->mailboxesSettings()->where('mailbox_id', $mailbox->id)->first();
if ($mailbox_settings) {
if ($mailbox->isArchived()) {
return false;
}
if (!empty(json_decode($mailbox_settings->access ?? ''))) {
return true;
}
}
}
return false;
}
/**
* Main function to check if user has some exta access permission
* for a given mailbox.
*
* $perm can be a single value or an array.
*/
public function hasManageMailboxPermission($mailbox_id, $perm) {
// Experimental feature.
public function hasManageMailboxPermission($mailbox_id, $perm)
{
// User can see only assigned conversations.
// This option does not affect admin users.
if ($perm == Mailbox::ACCESS_PERM_ASSIGNED) {
if ($this->isAdmin()) {
return false;
} else {
$show_only_assigned_conversations = config('app.show_only_assigned_conversations') ?? '';
return $this->hasPermission(self::PERM_ONLY_ASSIGNED_TICKETS);
/*$show_only_assigned_conversations = config('app.show_only_assigned_conversations') ?? '';
if (in_array($this->id, explode(',', $show_only_assigned_conversations))) {
return true;
} else {
return false;
}
}*/
}
}
@@ -384,16 +426,27 @@ class User extends Authenticatable
} else {
//$mailbox = $this->mailboxesCanViewWithSettings(true)->where('id', $mailbox_id)->first();
$mailbox = $this->mailboxesSettings()->where('mailbox_id', $mailbox_id)->first();
if ($mailbox && !empty($mailbox->access) && in_array($perm, json_decode($mailbox->access))) {
return true;
if ($mailbox && !empty($mailbox->access)) {
$mailbox_access = json_decode($mailbox->access);
if (!is_array($perm)) {
return in_array($perm, $mailbox_access);
} else {
// Check if any of permissions is included in mailbox access.
foreach ($perm as $perm_value) {
if (in_array($perm_value, $mailbox_access)) {
return true;
}
}
return false;
}
} else {
return false;
}
}
}
/**
* Generate random password for the user.
*
@@ -417,7 +470,7 @@ class User extends Authenticatable
public function isDummyPassword()
{
$decrypted_password = \Helper::decrypt($this->password);
return preg_match("#^dummy_#", $decrypted_password);
return preg_match("#^dummy_#", $this->password) || preg_match("#^dummy_#", $decrypted_password);
//return Hash::check($this->getDummyPassword(), $this->password);
}
@@ -428,7 +481,7 @@ class User extends Authenticatable
*/
public function url()
{
return route('users.profile', ['id'=>$this->id]);
return route('users.profile', ['id' => $this->id]);
}
/**
@@ -438,7 +491,9 @@ class User extends Authenticatable
*/
public function urlSetup()
{
return route('user_setup', ['hash' => $this->invite_hash]);
$invite_sent_at = \Helper::encrypt(time().'', $this->password);
return route('user_setup', ['hash' => $this->invite_hash, 'invite_sent_at' => $invite_sent_at]);
}
/**
@@ -639,11 +694,12 @@ class User extends Authenticatable
{
$user_permission_names = [
self::PERM_DELETE_CONVERSATIONS => __('Users are allowed to delete conversations'),
self::PERM_EDIT_CONVERSATIONS => __('Users are allowed to edit notes/replies'),
self::PERM_EDIT_CONVERSATIONS => __('Users are allowed to edit own notes/replies'),
self::PERM_EDIT_SAVED_REPLIES => __('Users are allowed to edit/delete saved replies'),
self::PERM_EDIT_TAGS => __('Users are allowed to manage tags'),
self::PERM_EDIT_CUSTOM_FOLDERS => __('Users are allowed to manage custom folders'),
self::PERM_EDIT_USERS => __('Users are allowed to manage users'),
self::PERM_ONLY_ASSIGNED_TICKETS => __('User can see only assigned conversations'),
];
if (!empty($user_permission_names[$user_permission])) {
@@ -747,7 +803,7 @@ class User extends Authenticatable
*/
public function setInviteHash()
{
$this->invite_hash = Str::random(60);
$this->invite_hash = Str::random(self::INVITE_HASH_LENGTH);
}
/**
@@ -821,7 +877,7 @@ class User extends Authenticatable
$notifications = $user->getWebsiteNotifications();
$info = [
'data' => WebsiteNotification::fetchNotificationsData($notifications),
'data' => WebsiteNotification::fetchNotificationsData($notifications, $this),
'notifications' => $notifications,
'unread_count' => $user->unreadNotifications()->count(),
];
@@ -834,7 +890,7 @@ class User extends Authenticatable
$notifications = $this->getWebsiteNotifications();
$info = [
'data' => WebsiteNotification::fetchNotificationsData($notifications),
'data' => WebsiteNotification::fetchNotificationsData($notifications, $this),
'notifications' => $notifications,
'unread_count' => $this->unreadNotifications()->count(),
];
@@ -884,7 +940,8 @@ class User extends Authenticatable
}
$file_name = md5(Hash::make($this->id)).'.jpg';
$dest_path = Storage::path(self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name);
$storage_path = self::PHOTO_DIRECTORY.DIRECTORY_SEPARATOR.$file_name;
$dest_path = Storage::disk('local')->path($storage_path);
$dest_dir = pathinfo($dest_path, PATHINFO_DIRNAME);
if (!file_exists($dest_dir)) {
@@ -901,6 +958,8 @@ class User extends Authenticatable
// User::PHOTO_DIRECTORY, !Hash::make($user->id).'.jpg'
// );
$file_name = \Eventy::filter('user.save_photo', $file_name, $this, $dest_path, $storage_path);
return $file_name;
}
@@ -915,6 +974,34 @@ class User extends Authenticatable
$this->photo_url = '';
}
public function addPermission($permission_id, $save = true)
{
$permissions = $this->permissions;
$permissions[$permission_id] = 1;
$this->permissions = $permissions;
if ($save) {
$this->save();
}
}
public function removePermission($permission_id, $save = true)
{
$permissions = $this->permissions;
if (isset($permissions[$permission_id])) {
unset($permissions[$permission_id]);
}
$this->permissions = $permissions;
if ($save) {
$this->save();
}
}
public function hasPermission($permission, $check_own_permissions = true)
{
$has_permission = false;
@@ -969,7 +1056,7 @@ class User extends Authenticatable
/**
* Create user.
*/
public static function create($data)
public static function create($data, $options = [])
{
$user = new self();
@@ -982,6 +1069,7 @@ class User extends Authenticatable
try {
$user->save();
} catch (\Exception $e) {
\Helper::logException($e);
return null;
}
@@ -1011,7 +1099,7 @@ class User extends Authenticatable
foreach ($fields_strip as $field) {
if (in_array($field, array_keys($data))) {
if ($data[$field] !== null) {
$data[$field] = strip_tags($data[$field]);
$data[$field] = \Helper::stripTags($data[$field]);
}
}
}
@@ -1026,6 +1114,10 @@ class User extends Authenticatable
}
}
}
// Role is not fillable.
if (!empty($data['role']) && array_key_exists((int)$data['role'], self::$roles)) {
$this->role = (int)$data['role'];
}
\Eventy::action('user.set_data', $this, $data, $replace_data);
@@ -1148,7 +1240,10 @@ class User extends Authenticatable
public function getAuthToken()
{
return md5($this->id.''.$this->created_at.config('app.key'));
$expiry = time()+2592000;
$hash = hash_hmac('sha256', $this->id.':'.$expiry, config('app.key').$this->password);
return urlencode(base64_encode($this->id.':'.$expiry.':'.$hash));
}
public static function findNonDeleted($id, $extended = false)
@@ -1195,6 +1290,23 @@ class User extends Authenticatable
return false;
}
public static function findByAlternateEmail($email)
{
$email = Email::sanitizeEmail($email);
$users = self::nonDeleted()
->where('emails', \Helper::sqlLikeOperator(), '%'.$email.'%')
->get();
foreach ($users as $user) {
if ($user->hasEmail($email)) {
return $user;
}
}
return null;
}
/**
* Check if there is a mailbox with specified email.
*/

View File

@@ -9,6 +9,16 @@
"email": "support@freescout.net"
}
],
"repositories": [
{
"type": "vcs",
"url": "https://github.com/freescout-help-desk/laravel-selfupdater"
},
{
"type": "vcs",
"url": "https://github.com/freescout-help-desk/Purifier"
}
],
"require": {
"php": ">=7.1.0",
@@ -17,7 +27,7 @@
"fideloper/proxy": "3.3.4",
"laravel/framework": "v5.5.40",
"laravel/tinker": "v1.0.7",
"mews/purifier": "3.2.2",
"mews/purifier": "dev-customized",
"spatie/laravel-activitylog": "2.7.0",
"lord/laroute": "2.4.7",
"axn/laravel-laroute": "1.3.0",
@@ -29,7 +39,7 @@
"chumper/zipper": "v1.0.2",
"rachidlaasri/laravel-installer": "4.0.2",
"rap2hpoutre/laravel-log-viewer": "v2.0.0",
"codedge/laravel-selfupdater": "1.4.3",
"codedge/laravel-selfupdater": "dev-customized",
"doctrine/dbal": "2.12.1",
"egulias/email-validator": "2.1.10",
"symfony/console": "v3.4.47",
@@ -37,7 +47,7 @@
"ramsey/uuid": "3.9.6",
"symfony/polyfill-mbstring": "v1.24.0",
"webklex/php-imap": "4.1.1",
"enshrined/svg-sanitize": "0.15.4"
"enshrined/svg-sanitize": "0.22.0"
},
"require-dev": {
"barryvdh/laravel-debugbar": "v3.2.0",
@@ -53,6 +63,7 @@
"symfony/routing": "v3.4.18",
"symfony/process": "v3.4.18",
"symfony/polyfill-php70": "v1.10.*",
"symfony/polyfill-intl-idn": "v1.38.1",
"symfony/http-foundation": "v3.4.18",
"symfony/event-dispatcher": "v3.4.18",
"psr/log": "1.0.*",
@@ -65,8 +76,7 @@
"league/flysystem": "1.1.4",
"erusev/parsedown": "1.7.2",
"doctrine/inflector": "v1.2.*",
"guzzlehttp/guzzle": "6.5.8",
"guzzlehttp/psr7": "1.9.1",
"guzzlehttp/guzzle": "8.0.1",
"tedivm/jshrink": "1.4.0",
"nikic/php-parser": "^4.1",
"doctrine/annotations": "v1.4.*",
@@ -75,18 +85,17 @@
"doctrine/instantiator": "1.3.1",
"myclabs/deep-copy": "1.10.1",
"webmozart/assert": "1.3.0",
"psr/container": "1.0.0",
"psr/http-message": "1.0.1"
"psr/container": "1.0.0"
},
"autoload": {
"classmap": [
"database/seeds",
"database/factories",
"overrides/natxet/cssmin/src/"
"overrides/natxet/cssmin/src/",
"overrides/erusev/parsedown/"
],
"psr-0": {
"Swift_": "overrides/swiftmailer/swiftmailer/lib/classes",
"HTMLPurifier_": "overrides/ezyang/htmlpurifier/library"
"Swift_": "overrides/swiftmailer/swiftmailer/lib/classes"
},
"files": [
"app/Misc/Functions.php"
@@ -103,6 +112,7 @@
"RachidLaasri\\LaravelInstaller\\Providers\\": "overrides/rachidlaasri/laravel-installer/src/Providers/",
"RachidLaasri\\LaravelInstaller\\Events\\": "overrides/rachidlaasri/laravel-installer/src/Events/",
"Nwidart\\Modules\\": "overrides/nwidart/laravel-modules/src/",
"Nwidart\\Modules\\Generators\\": "overrides/nwidart/laravel-modules/src/Generators/",
"Codedge\\Updater\\SourceRepositoryTypes\\": "overrides/codedge/laravel-selfupdater/src/SourceRepositoryTypes/",
"Barryvdh\\TranslationManager\\": "overrides/barryvdh/laravel-translation-manager/src/",
"Illuminate\\Foundation\\": "overrides/laravel/framework/src/Illuminate/Foundation/",
@@ -148,6 +158,7 @@
"Illuminate\\Broadcasting\\": "overrides/laravel/framework/src/Illuminate/Broadcasting/",
"Illuminate\\Bus\\": "overrides/laravel/framework/src/Illuminate/Bus/",
"Illuminate\\Pipeline\\": "overrides/laravel/framework/src/Illuminate/Pipeline/",
"Illuminate\\Events\\": "overrides/laravel/framework/src/Illuminate/Events/",
"Doctrine\\DBAL\\Driver\\": "overrides/doctrine/dbal/lib/Doctrine/DBAL/Driver/",
"Doctrine\\DBAL\\Schema\\": "overrides/doctrine/dbal/lib/Doctrine/DBAL/Schema/",
"Doctrine\\DBAL\\Platforms\\": "overrides/doctrine/dbal/lib/Doctrine/DBAL/Platforms/",
@@ -169,7 +180,9 @@
"Illuminate\\Console\\": "overrides/laravel/framework/src/Illuminate/Console/",
"Illuminate\\Log\\": "overrides/laravel/framework/src/Illuminate/Log/",
"Illuminate\\Validation\\": "overrides/laravel/framework/src/Illuminate/Validation/",
"Illuminate\\Contracts\\Container\\": "overrides/laravel/framework/src/Illuminate/Contracts/Container/",
"Symfony\\Component\\Routing\\": "overrides/symfony/routing/",
"Symfony\\Component\\Routing\\Generator\\": "overrides/symfony/routing/generator/",
"Symfony\\Component\\VarDumper\\Cloner\\": "overrides/symfony/var-dumper/Cloner/",
"Symfony\\Component\\VarDumper\\Dumper\\": "overrides/symfony/var-dumper/Dumper/",
"Devfactory\\Minify\\Providers\\": "overrides/devfactory/minify/src/Providers/",
@@ -182,11 +195,6 @@
"Symfony\\Component\\HttpKernel\\HttpCache\\": "overrides/symfony/http-kernel/HttpCache/",
"Symfony\\Component\\Debug\\Exception\\": "overrides/symfony/debug/Exception/",
"Spatie\\String\\": "overrides/spatie/string/src/",
"GuzzleHttp\\": "overrides/guzzlehttp/guzzle/src/",
"GuzzleHttp\\Cookie\\": "overrides/guzzlehttp/guzzle/src/Cookie/",
"GuzzleHttp\\Handler\\": "overrides/guzzlehttp/guzzle/src/Handler/",
"GuzzleHttp\\Psr7\\": "overrides/guzzlehttp/psr7/src/",
"GuzzleHttp\\Promise\\": "overrides/guzzlehttp/promises/src/",
"Ramsey\\Uuid\\": "overrides/ramsey/uuid/src/",
"Rap2hpoutre\\LaravelLogViewer\\": "overrides/rap2hpoutre/laravel-log-viewer/src/Rap2hpoutre/LaravelLogViewer/",
"Symfony\\Component\\Console\\Descriptor\\": "overrides/symfony/console/Descriptor/",
@@ -195,6 +203,8 @@
"Symfony\\Component\\Console\\Exception\\": "overrides/symfony/console/Exception/",
"Symfony\\Component\\Console\\Question\\": "overrides/symfony/console/",
"Symfony\\Component\\CssSelector\\XPath\\Extension\\": "overrides/symfony/css-selector/XPath/Extension/",
"Symfony\\Component\\CssSelector\\XPath\\": "overrides/symfony/css-selector/XPath/",
"Symfony\\Component\\CssSelector\\Parser\\": "overrides/symfony/css-selector/Parser/",
"Symfony\\Component\\Translation\\Loader\\": "overrides/symfony/translation/Loader/",
"Symfony\\Component\\Translation\\Formatter\\": "overrides/symfony/translation/Formatter/",
"Symfony\\Component\\Translation\\": "overrides/symfony/translation/",
@@ -203,7 +213,6 @@
"Whoops\\": "overrides/filp/whoops/src/Whoops/",
"Whoops\\Handler\\": "overrides/filp/whoops/src/Whoops/Handler/",
"Whoops\\Util\\": "overrides/filp/whoops/src/Whoops/Util/",
"Mews\\Purifier\\": "overrides/mews/purifier/src/",
"Cron\\": "overrides/mtdowling/cron-expression/src/Cron/",
"Psy\\": "overrides/psy/psysh/src/",
"Psy\\Input\\": "overrides/psy/psysh/src/Input/",
@@ -212,7 +221,14 @@
"Psy\\TimeitCommand\\": "overrides/psy/psysh/src/TimeitCommand/",
"Psy\\Command\\ListCommand\\": "overrides/psy/psysh/src/Command/ListCommand/",
"Psy\\Exception\\": "overrides/psy/psysh/src/ErrorException/",
"Psy\\VarDumper\\": "overrides/psy/psysh/src/VarDumper/"
"Psy\\VarDumper\\": "overrides/psy/psysh/src/VarDumper/",
"TijsVerkoyen\\CssToInlineStyles\\": "overrides/tijsverkoyen/css-to-inline-styles/src/",
"TijsVerkoyen\\CssToInlineStyles\\Css\\Property\\": "overrides/tijsverkoyen/css-to-inline-styles/src/Css/Property/",
"Html2Text\\": "overrides/html2text/html2text/src/",
"PHPUnit\\Runner\\": "overrides/phpunit/phpunit/src/Runner/",
"PHPUnit\\Util\\PHP\\": "overrides/phpunit/phpunit/src/Util/PHP/",
"PHPUnit\\Util\\Xml\\": "overrides/phpunit/phpunit/src/Util/Xml/",
"Illuminate\\Http\\Exceptions\\": "overrides/laravel/framework/src/Illuminate/Http/Exceptions/"
},
"exclude-from-classmap": [
"Modules/",
@@ -230,6 +246,7 @@
"vendor/nwidart/laravel-modules/src/Module.php",
"vendor/nwidart/laravel-modules/src/Repository.php",
"vendor/nwidart/laravel-modules/src/Json.php",
"vendor/nwidart/laravel-modules/src/Generators/ModuleGenerator.php",
"vendor/codedge/laravel-selfupdater/src/SourceRepositoryTypes/GithubRepositoryType.php",
"vendor/barryvdh/laravel-translation-manager/src/Manager.php",
"vendor/barryvdh/laravel-translation-manager/src/Controller.php",
@@ -244,6 +261,7 @@
"vendor/laravel/framework/src/Illuminate/Validation/Concerns/ValidatesAttributes.php",
"vendor/laravel/framework/src/Illuminate/Validation/Concerns/FormatsMessages.php",
"vendor/laravel/framework/src/Illuminate/Mail/TransportManager.php",
"vendor/laravel/framework/src/Illuminate/Mail/Message.php",
"vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php",
"vendor/laravel/framework/src/Illuminate/Auth/Middleware/Authenticate.php",
"vendor/laravel/framework/src/Illuminate/Container/Container.php",
@@ -261,7 +279,6 @@
"vendor/symfony/http-foundation/File/MimeType/FileBinaryMimeTypeGuesser.php",
"vendor/chumper/zipper/src/Chumper/Zipper/Repositories/ZipRepository.php",
"vendor/fzaninotto/faker/src/Faker/Provider/Base.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/AttrDef/URI/Host.php",
"vendor/laravel/framework/src/Illuminate/Database/DetectsLostConnections.php",
"vendor/laravel/framework/src/Illuminate/Support/Collection.php",
@@ -279,6 +296,7 @@
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasGlobalScopes.php",
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php",
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Relations/Relation.php",
"vendor/laravel/framework/src/Illuminate/Database/Eloquent/Relations/BelongsTo.php",
"vendor/laravel/framework/src/Illuminate/Database/Schema/Builder.php",
"vendor/laravel/framework/src/Illuminate/Database/Schema/Blueprint.php",
"vendor/laravel/framework/src/Illuminate/Pagination/AbstractPaginator.php",
@@ -286,8 +304,13 @@
"vendor/laravel/framework/src/Illuminate/Pagination/LengthAwarePaginator.php",
"vendor/laravel/framework/src/Illuminate/Routing/RouteCollection.php",
"vendor/laravel/framework/src/Illuminate/Session/FileSessionHandler.php",
"vendor/laravel/framework/src/Illuminate/Session/NullSessionHandler.php",
"vendor/laravel/framework/src/Illuminate/Session/CacheBasedSessionHandler.php",
"vendor/laravel/framework/src/Illuminate/Session/CookieSessionHandler.php",
"vendor/laravel/framework/src/Illuminate/Session/DatabaseSessionHandler.php",
"vendor/laravel/framework/src/Illuminate/Session/Middleware/StartSession.php",
"vendor/laravel/framework/src/Illuminate/Queue/Listener.php",
"vendor/laravel/framework/src/Illuminate/Queue/SerializesModels.php",
"vendor/nesbot/carbon/src/Carbon/Carbon.php",
"vendor/nesbot/carbon/src/Carbon/Translator.php",
"vendor/laravel/framework/src/Illuminate/Cache/Repository.php",
@@ -300,6 +323,7 @@
"vendor/doctrine/dbal/lib/Doctrine/DBAL/Platforms/PostgreSqlPlatform.php",
"vendor/laravel/framework/src/Illuminate/Support/Carbon.php",
"vendor/laravel/framework/src/Illuminate/Support/Str.php",
"vendor/laravel/framework/src/Illuminate/Support/Arr.php",
"vendor/laravel/framework/src/Illuminate/Support/ViewErrorBag.php",
"vendor/laravel/framework/src/Illuminate/Support/MessageBag.php",
"vendor/laravel/framework/src/Illuminate/Support/Optional.php",
@@ -334,6 +358,7 @@
"vendor/laravel/framework/src/Illuminate/View/Concerns/ManagesLayouts.php",
"vendor/symfony/routing/Route.php",
"vendor/symfony/routing/CompiledRoute.php",
"vendor/symfony/routing/Generator/UrlGenerator.php",
"vendor/symfony/var-dumper/Cloner/Data.php",
"vendor/symfony/var-dumper/Cloner/Stub.php",
"vendor/symfony/var-dumper/Cloner/AbstractCloner.php",
@@ -341,6 +366,7 @@
"vendor/devfactory/minify/src/Providers/BaseProvider.php",
"vendor/barryvdh/laravel-debugbar/src/DataFormatter/QueryFormatter.php",
"vendor/barryvdh/laravel-debugbar/src/DataCollector/QueryCollector.php",
"vendor/barryvdh/laravel-debugbar/src/DataCollector/ViewCollector.php",
"vendor/symfony/process/Process.php",
"vendor/symfony/http-kernel/UriSigner.php",
"vendor/symfony/http-kernel/Exception/HttpException.php",
@@ -352,27 +378,16 @@
"vendor/symfony/http-kernel/HttpCache/Store.php",
"vendor/barryvdh/laravel-debugbar/src/JavascriptRenderer.php",
"vendor/spatie/string/src/Str.php",
"vendor/guzzlehttp/guzzle/src/Client.php",
"vendor/guzzlehttp/guzzle/src/Cookie/CookieJar.php",
"vendor/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php",
"vendor/guzzlehttp/guzzle/src/Handler/StreamHandler.php",
"vendor/guzzlehttp/guzzle/src/HandlerStack.php",
"vendor/guzzlehttp/guzzle/src/Middleware.php",
"vendor/guzzlehttp/psr7/src/LazyOpenStream.php",
"vendor/guzzlehttp/psr7/src/Uri.php",
"vendor/guzzlehttp/promises/src/RejectedPromise.php",
"vendor/guzzlehttp/promises/src/PromiseInterface.php",
"vendor/guzzlehttp/promises/src/FulfilledPromise.php",
"vendor/guzzlehttp/promises/src/Utils.php",
"vendor/guzzlehttp/promises/src/Promise.php",
"vendor/ramsey/uuid/src/Uuid.php",
"vendor/ramsey/uuid/src/UuidFactory.php",
"vendor/laravel/framework/src/Illuminate/Routing/Router.php",
"vendor/laravel/framework/src/Illuminate/Filesystem/Filesystem.php",
"vendor/laravel/framework/src/Illuminate/Console/Application.php",
"vendor/laravel/framework/src/Illuminate/Console/Command.php",
"vendor/rap2hpoutre/laravel-log-viewer/src/Rap2hpoutre/LaravelLogViewer/LaravelLogViewer.php",
"vendor/rap2hpoutre/laravel-log-viewer/src/controllers/LogViewerController.php",
"vendor/rap2hpoutre/laravel-log-viewer/src/Rap2hpoutre/LaravelLogViewer/LogViewerController.php",
"vendor/symfony/console/Descriptor/TextDescriptor.php",
"vendor/symfony/console/Input/ArgvInput.php",
"vendor/symfony/console/Input/ArrayInput.php",
"vendor/symfony/console/Input/StringInput.php",
"vendor/symfony/console/Input/Input.php",
@@ -380,21 +395,17 @@
"vendor/symfony/console/Helper/ProcessHelper.php",
"vendor/symfony/console/Command/Command.php",
"vendor/symfony/console/Output/Output.php",
"vendor/symfony/console/Output/StreamOutput.php",
"vendor/symfony/console/Formatter/OutputFormatterStyleStack.php",
"vendor/symfony/console/Exception/CommandNotFoundException.php",
"vendor/symfony/console/Question/Question.php",
"vendor/symfony/finder/Iterator/SortableIterator.php",
"vendor/symfony/css-selector/XPath/Extension/NodeExtension.php",
"vendor/symfony/css-selector/XPath/Translator.php",
"vendor/symfony/css-selector/Parser/Parser.php",
"vendor/symfony/translation/Loader/ArrayLoader.php",
"vendor/symfony/translation/Formatter/MessageFormatter.php",
"vendor/symfony/translation/Translator.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/AttrTransform/NameSync.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/AttrValidator.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/ChildDef/List.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/Length.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/Encoder.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/Lexer.php",
"vendor/ezyang/htmlpurifier/library/HTMLPurifier/Lexer/DOMLex.php",
"vendor/javoscript/laravel-macroable-models/src/MacroableModels.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Attachment.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/EmbeddedFile.php",
@@ -419,12 +430,14 @@
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Mime/Headers/MailboxHeader.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/AbstractSmtpTransport.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/EsmtpTransport.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/SendmailTransport.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/MailInvoker.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/MailTransport.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/SimpleMailInvoker.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/StreamBuffer.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Transport/Esmtp/AuthHandler.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/Encoder/QpEncoder.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/KeyCache/ArrayKeyCache.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/KeyCache/DiskKeyCache.php",
"vendor/swiftmailer/swiftmailer/lib/classes/Swift/KeyCache/SimpleKeyCacheInputStream.php",
"vendor/natxet/cssmin/src/CssMin.php",
@@ -451,20 +464,22 @@
"vendor/laravel/framework/src/Illuminate/Broadcasting/BroadcastManager.php",
"vendor/laravel/framework/src/Illuminate/Bus/Dispatcher.php",
"vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"vendor/laravel/framework/src/Illuminate/Events/Dispatcher.php",
"vendor/league/flysystem/src/Filesystem.php",
"vendor/league/flysystem/src/FilesystemInterface.php",
"vendor/league/flysystem/src/FileNotFoundException.php",
"vendor/mews/purifier/src/Purifier.php",
"vendor/mtdowling/cron-expression/src/Cron/CronExpression.php",
"vendor/laravel/framework/src/Illuminate/Mail/Mailer.php",
"vendor/filp/whoops/src/Whoops/Run.php",
"vendor/filp/whoops/src/Whoops/Handler/PrettyPageHandler.php",
"vendor/filp/whoops/src/Whoops/Handler/PlainTextHandler.php",
"vendor/filp/whoops/src/Whoops/Util/TemplateHelper.php",
"vendor/laravel/framework/src/Illuminate/Validation/Factory.php",
"vendor/laravel/framework/src/Illuminate/Validation/ValidationRuleParser.php",
"vendor/laravel/framework/src/Illuminate/Foundation/Validation/ValidatesRequests.php",
"vendor/chumper/zipper/src/Chumper/Zipper/Zipper.php",
"vendor/psy/psysh/src/Shell.php",
"vendor/psy/psysh/src/Configuration.php",
"vendor/psy/psysh/src/CodeCleaner.php",
"vendor/psy/psysh/src/Input/FilterOptions.php",
"vendor/psy/psysh/src/Input/ShellInput.php",
@@ -480,7 +495,16 @@
"vendor/psy/psysh/src/Exception/ErrorException.php",
"vendor/psy/psysh/src/Exception/BreakException.php",
"vendor/psy/psysh/src/VarDumper/Dumper.php",
"vendor/laravel/framework/src/Illuminate/Foundation/Application.php"
"vendor/laravel/framework/src/Illuminate/Foundation/Application.php",
"vendor/laravel/framework/src/Illuminate/Contracts/Container/Container.php",
"vendor/erusev/parsedown/Parsedown.php",
"vendor/tijsverkoyen/css-to-inline-styles/src/CssToInlineStyles.php",
"vendor/tijsverkoyen/css-to-inline-styles/src/Css/Property/Processor.php",
"vendor/html2text/html2text/src/Html2Text.php",
"vendor/phpunit/phpunit/src/Runner/PhpTestCase.php",
"vendor/phpunit/phpunit/src/Util/PHP/AbstractPhpProcess.php",
"vendor/phpunit/phpunit/src/Util/Xml/Loader.php",
"vendor/laravel/framework/src/Illuminate/Http/Exceptions/PostTooLargeException.php"
]
},
"autoload-dev": {
@@ -504,9 +528,14 @@
"pre-install-cmd": [
"@php -r \"@mkdir('vendor/natxet/cssmin/src', 775);\""
],
"pre-autoload-dump": [
"@php -r \"file_put_contents('vendor/psy/psysh/src/functions.php', '<?php require_once __DIR__ . \\'/../../../../overrides/psy/psysh/src/functions.php\\';');\"",
"@php -r \"file_put_contents('vendor/laravel/framework/src/Illuminate/Support/helpers.php', '<?php require_once __DIR__ . \\'/../../../../../../overrides/laravel/framework/src/Illuminate/Support/helpers.php\\';');\"",
"@php -r \"file_put_contents('vendor/spatie/laravel-activitylog/src/helpers.php', '<?php require_once __DIR__ . \\'/../../../../overrides/spatie/laravel-activitylog/src/helpers.php\\';');\""
],
"post-autoload-dump": [
"@php -r \"file_put_contents('vendor/composer/ClassLoader.php', preg_replace('/function includeFile.*/s', base64_decode('DQpmdW5jdGlvbiBpbmNsdWRlRmlsZSgkZmlsZSkNCnsNCiAgICB0cnkgew0KICAgICAgICBpbmNsdWRlICRmaWxlOw0KICAgIH0gY2F0Y2ggKFxFeGNlcHRpb24gJGUpIHsNCiAgICAgICAgJG1zZyA9ICRlLT5nZXRNZXNzYWdlKCk7DQogICAgICAgIGlmIChzdHJzdHIoJG1zZywgJ05vIHN1Y2ggZmlsZSBvciBkaXJlY3RvcnknKSkgew0KICAgICAgICAgICAgaWYgKHN0cnN0cigkbXNnLCAnL3ZlbmRvci9jb21wb3Nlci8uLi8uLi9vdmVycmlkZXMvJykpIHsNCiAgICAgICAgICAgICAgICAkbmV3X2ZpbGUgPSBzdHJfcmVwbGFjZSgnL3ZlbmRvci9jb21wb3Nlci8uLi8uLi9vdmVycmlkZXMvJywgJy92ZW5kb3IvY29tcG9zZXIvLi4vJywgJGZpbGUpOw0KICAgICAgICAgICAgfSBlbHNlIHsNCiAgICAgICAgICAgICAgICAkbmV3X2ZpbGUgPSBzdHJfcmVwbGFjZSgnL3ZlbmRvci9jb21wb3Nlci8uLi8nLCAnL3ZlbmRvci9jb21wb3Nlci8uLi8uLi9vdmVycmlkZXMvJywgJGZpbGUpOw0KICAgICAgICAgICAgfQ0KICAgICAgICAgICAgaW5jbHVkZSAkbmV3X2ZpbGU7DQogICAgICAgIH0gZWxzZSB7DQogICAgICAgICAgICB0aHJvdyAkZTsNCiAgICAgICAgfQ0KICAgIH0NCn0='), file_get_contents('vendor/composer/ClassLoader.php')));\"",
"@php -r \"!error_reporting(0) || array_map('unlink', json_decode(file_get_contents('composer.json'), true)['autoload']['exclude-from-classmap']);\"",
"@php -r \"(error_reporting(0) || !error_reporting(0)) && array_map('unlink', json_decode(file_get_contents('composer.json'), true)['autoload']['exclude-from-classmap']);\"",
"@php -r \"file_put_contents('vendor/webklex/php-imap/src/config/imap.php', preg_replace('/, \\\\'inline\\\\'],/s', '/*, \\\\'inline\\\\'*/],', file_get_contents('vendor/webklex/php-imap/src/config/imap.php')));\"",
"@php -r \"copy('vendor/nesbot/carbon/src/Carbon/Lang/pt.php', 'vendor/nesbot/carbon/src/Carbon/Lang/pt_PT.php');\"",
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",

1124
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ return [
| or any other location as required by the application or its packages.
*/
'version' => '1.8.188',
'version' => '1.8.245',
/*
|--------------------------------------------------------------------------
@@ -98,8 +98,8 @@ return [
*/
'locale' => env('APP_LOCALE', 'en'),
'locales' => ['en', 'ar', 'zh-CN', 'hr', 'cs', 'da', 'nl', 'fi', 'fr', 'de', 'hu', 'it', 'ja', 'kz', 'ko', 'no', 'fa', 'pl', 'pt-PT', 'pt-BR', 'ro', 'ru', 'es', 'sk', 'sv', 'tr'],
'locales_rtl' => ['ar', 'fa'],
'locales' => ['en', 'ar', 'az', 'ca', 'zh-CN', 'zh-TW', 'hr', 'cs', 'da', 'nl', 'fi', 'fr', 'de', 'he', 'hu', 'it', 'ja', 'kz', 'ko', 'no', 'fa', 'pl', 'pt-PT', 'pt-BR', 'ro', 'ru', 'es', 'sk', 'sl', 'sv', 'tr', 'uk'],
'locales_rtl' => ['ar', 'fa', 'he'],
'default_locale' => 'en',
/*
@@ -165,7 +165,7 @@ return [
| FreeScout API
|-------------------------------------------------------------------------
*/
'freescout_api' => 'https://freescout.net/wp-json/',
'freescout_api' => 'https://api.freescout.net/wp-json/',
'freescout_alt_api' => 'https://cdn.freescout.net/wp-json/',
/*
@@ -188,7 +188,7 @@ return [
| Checks for new jobs every --sleep seconds.
| If --tries is set and job fails it is being processed right away without any delay.
| --delay parameter does not work to set delays between retry attempts.
| --timeout parameter sets job timeout and is used to avoid queue:work freezing.
| --timeout parameter sets job timeout in seconds and is used to avoid queue:work freezing.
|
| Jobs sending emails are retried manually in handle().
| Number of retries is set in each job class.
@@ -220,6 +220,7 @@ return [
*/
'fetch_schedule' => env('APP_FETCH_SCHEDULE', 1),
'fetch_unseen' => env('APP_FETCH_UNSEEN', 1),
'fetch_max_execution_time' => env('APP_FETCH_MAX_EXECUTION_TIME', 30), // minutes
/*
|--------------------------------------------------------------------------
@@ -276,6 +277,37 @@ return [
// If HTML file is renamed into .txt for example it will be shown by the browser as HTML.
// Regular expressions (#...#)
'viewable_mime_types' => env('APP_VIEWABLE_MIME_TYPES', ['image/.*', 'application/pdf', 'text/plain', 'text/x-diff', 'application/json', 'audio/.*']),
'non_viewable_mime_types' => env('APP_NON_VIEWABLE_MIME_TYPES', ['image/svg.*']),
/*
|--------------------------------------------------------------------------
| File extensions that support agents are allowed to upload.
| Files with other extensions will be rejected.
| Must be in sync with corresponding parameter in .env.example.
|
| Should contain comma separated list: jpg,jpeg,png,gif...
|-------------------------------------------------------------------------
*/
'allowed_extensions' => env('APP_ALLOWED_EXTENSIONS')
? explode(',', env('APP_ALLOWED_EXTENSIONS'))
: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp3', 'wav', 'ogg', 'wma', 'pdf', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx'],
/*
|--------------------------------------------------------------------------
| Allowed extensions and mime types for files uploaded by customers.
| Files with other extensions and mime types will be renamed.
|
| APP_CUSTOMER_UPLOADABLE_EXTENSIONS should contain comma separated list: jpg,jpeg,png,gif...
| Same for APP_CUSTOMER_UPLOADABLE_MIME_TYPES.
|-------------------------------------------------------------------------
*/
'customer_allowed_extensions' => env('APP_CUSTOMER_ALLOWED_EXTENSIONS')
? explode(',', env('APP_CUSTOMER_ALLOWED_EXTENSIONS'))
: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp3', 'wav', 'ogg', 'wma', 'pdf', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx'],
'customer_allowed_mime_types' => env('APP_CUSTOMER_ALLOWED_MIME_TYPES')
? explode(',', env('APP_CUSTOMER_ALLOWED_MIME_TYPES'))
: ['image/jpg', 'image/jpeg', 'image/gif', 'image/bmp', 'image/x-ms-bmp', 'image/webp', 'audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/ogg', 'audio/x-ms-wma', 'application/pdf', 'text/plain', 'text/csv', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
/*
|--------------------------------------------------------------------------
@@ -286,10 +318,11 @@ return [
| to avoid sending multiple duplicate emails to other recipients.
|
| https://github.com/freescout-helpdesk/freescout/issues/870#issuecomment-786477909
| https://github.com/freescout-help-desk/freescout/issues/5666
|
|-------------------------------------------------------------------------
*/
'no_retry_mail_errors' => env('APP_NO_RETRY_MAIL_ERRORS', '(no valid recipients|does not comply with RFC|message file too big|malformed address)'),
'no_retry_mail_errors' => env('APP_NO_RETRY_MAIL_ERRORS', '(no valid recipients|does not comply with RFC|message file too big|malformed address|message size|message too large|size exceeded)'),
/*
|--------------------------------------------------------------------------
@@ -399,8 +432,12 @@ return [
/*
|--------------------------------------------------------------------------
| When proxy is specified, all requests to external resources are made vie this proxy.
|
| Enter your proxy address in .env file if freescout.net is not available from your server
| (access to freescout.net is required to obtain official modules)
| (access to freescout.net is required to obtain official modules).
|
| Example: https://123.45.67.89:4321
|--------------------------------------------------------------------------
*/
'proxy' => env('APP_PROXY', ''),
@@ -426,7 +463,7 @@ return [
// Need to be set for curl. Guzzle sends it's own user agent: GuzzleHttp/6.3.3 curl/7.58.0 PHP/8.2.5
'curl_user_agent' => env('APP_CURL_USER_AGENT', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 7_1_4) AppleWebKit/603.26 (KHTML, like Gecko) Chrome/55.0.3544.220 Safari/534'),
// Should be set for curl and Guzzle.
'curl_ssl_verifypeer' => env('APP_CURL_SSL_VERIFYPEER', false),
'curl_ssl_verifypeer' => env('APP_CURL_SSL_VERIFYPEER', true),
/*
|--------------------------------------------------------------------------
@@ -455,28 +492,25 @@ return [
*/
'update_folder_counters_in_background' => env('APP_UPDATE_FOLDER_COUNTERS_IN_BACKGROUND', false),
/*
|--------------------------------------------------------------------------
| Experimental feature allowing to specify users who can see only conversations
| assigned to themselves. For such users only Mine folder shows actual number of conversations.
| This option does not affect admin users.
|
| The option should be specified as a comma separated list of user IDs which
| can be found in the their profile URL (/users/profile/7).
|
| Example: 7,5,31
|-------------------------------------------------------------------------
*/
'show_only_assigned_conversations' => env('APP_SHOW_ONLY_ASSIGNED_CONVERSATIONS', ''),
/*
|--------------------------------------------------------------------------
| Limit non-admin users to only see customers with conversations
| in mailboxes they are assigned to. This option does not affect admin users.
|
| Before creating an Security Advisory make sure to read about this parameter in Guidelines:
| https://github.com/freescout-help-desk/freescout/security/policy
|-------------------------------------------------------------------------
*/
'limit_user_customer_visibility' => env('APP_LIMIT_USER_CUSTOMER_VISIBILITY', false),
/*
|--------------------------------------------------------------------------
| Treat Waiting Since column as "Time of the first unanswered customer message"
| instead of "Time of the last customer activity".
|-------------------------------------------------------------------------
*/
'waiting_since_as_first_unanswered_customer_message' => env('WAITING_SINCE_AS_FIRST_UNANSWERED_CUSTOMER_MESSAGE', false),
/*
|--------------------------------------------------------------------------
| By default X-Frame-Options header is enabled and set to SAMEORIGIN.
@@ -489,10 +523,10 @@ return [
/*
|--------------------------------------------------------------------------
| Enable Content-Security-Policy meta tag to prevent possible XSS attacks.
| Content-Security-Policy meta tag parameters.
|-------------------------------------------------------------------------
*/
'csp_enabled' => env('APP_CSP_ENABLED', true),
//'csp_enabled' => env('APP_CSP_ENABLED', true),
'csp_script_src' => env('APP_CSP_SCRIPT_SRC', ''),
'csp_custom' => env('APP_CSP_CUSTOM', ''),
@@ -507,12 +541,47 @@ return [
|--------------------------------------------------------------------------
| When this option is enabled you may see an extra text below customer's replies, for example:
| On Thu, Jan 4, 2024 at 8:41 AM John Doe | Demo <test@example.org> wrote:
|
| But overall reply separation in this case is more reliable.
| No other reply separators are used in this case.
| When this option is enabled the overall reply separation is more reliable.
|-------------------------------------------------------------------------
*/
'alternative_reply_separation' => env('APP_ALTERNATIVE_REPLY_SEPARATION', false),
/*
|--------------------------------------------------------------------------
| Comma separated list of white listed hosts.
| If some input containing URL becomes blank after saving it - add its host or IP here.
| Example: example.org,test.example.org,192.168.1.97
|-------------------------------------------------------------------------
*/
'remote_host_white_list' => env('APP_REMOTE_HOST_WHITE_LIST', ''),
/*
|--------------------------------------------------------------------------
| By default only the host specified in APP_URL is allowed.
| To allow additional hosts list them separating by commas.
|--------------------------------------------------------------------------
*/
'trusted_hosts' => env('APP_TRUSTED_HOSTS', ''),
/*
|--------------------------------------------------------------------------
| Disable checking if visitor's browser supports CSP.
| It's NOT recommended to disable this check.
|--------------------------------------------------------------------------
*/
'disable_browser_check' => env('APP_DISABLE_BROWSER_CHECK', ''),
/*
|--------------------------------------------------------------------------
| If you get an error message saying that your browser doesn't support CSP,
| list your User-Agent in this parameter (https://www.whatismybrowser.com/detect/what-is-my-user-agent/).
| Use "|" symbol to separate multiple User-Agents.
| And don't forget to send your User-Agent to this issue: https://github.com/freescout-help-desk/freescout/issues/5331
|--------------------------------------------------------------------------
*/
'allowed_user_agents' => env('APP_ALLOWED_USER_AGENTS', ''),
/*
|--------------------------------------------------------------------------
| Autoloaded Service Providers

View File

@@ -96,7 +96,8 @@ return [
'provider' => 'users',
'table' => 'password_resets',
'expire' => 60,
'throttle' => 60,
// Has no effect.
//'throttle' => 60,
],
],

View File

@@ -53,17 +53,19 @@ return [
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('DB_MYSQL_ATTR_SSL_CA'),
PDO::MYSQL_ATTR_SSL_CERT => env('DB_MYSQL_ATTR_SSL_CERT'),
(defined('Pdo\Mysql::ATTR_SSL_CA') ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('DB_MYSQL_ATTR_SSL_CA'),
(defined('Pdo\Mysql::ATTR_SSL_CERT') ? Pdo\Mysql::ATTR_SSL_CERT : PDO::MYSQL_ATTR_SSL_CERT) => env('DB_MYSQL_ATTR_SSL_CERT'),
// https://github.com/freescout-help-desk/freescout/issues/5273
(defined('Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT') ? Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT : (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') ? PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT : -1)) => env('DB_MYSQL_ATTR_SSL_VERIFY_SERVER_CERT', true),
PDO::ATTR_PERSISTENT => env('DB_ATTR_PERSISTENT'),
]) : [],
], function($value) { return $value !== null; }) : [],
],
'testing' => [
'driver' => 'mysql',
//'url' => env('DB_TEST_DATABASE_URL'),
'host' => '127.0.0.1',
'database' => 'freescout-test',
'host' => env('DB_TEST_HOST', '127.0.0.1'),
'database' => env('DB_TEST_DATABASE', 'freescout-test'),
'username' => env('DB_TEST_USERNAME', 'freescout-test'),
'password' => env('DB_TEST_PASSWORD', 'freescout-test'),
//'port' => env('DB_TEST_PORT', '3306'),
@@ -100,7 +102,7 @@ return [
'schema' => 'public',
'sslmode' => env('DB_PGSQL_SSLMODE', 'prefer'),
'options' => extension_loaded('pdo_pgsql') ? array_filter([
PDO::PGSQL_ATTR_DISABLE_PREPARES => env('DB_PGSQL_ATTR_DISABLE_PREPARES'),
(defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pdo\Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_PGSQL_ATTR_DISABLE_PREPARES'),
PDO::ATTR_PERSISTENT => env('DB_ATTR_PERSISTENT'),
]) : [],
],
@@ -144,7 +146,7 @@ return [
'redis' => [
'client' => 'predis',
'client' => env('REDIS_CLIENT', 'predis'),
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),

Some files were not shown because too many files have changed in this diff Show More