Merge branch 'master' of github.com:freescout-helpdesk/freescout into dist

This commit is contained in:
FreeScout
2026-10-02 21:29:55 -07:00
36 changed files with 766 additions and 144 deletions

View File

@@ -1236,7 +1236,7 @@ class FetchEmails extends Command
$now = date('Y-m-d H:i:s');
}
$conv_cc = $cc;
//$prev_conv_cc = $conv_cc;
$prev_conv_cc = $conv_cc;
// Customers are created before with email and name
$customer = Customer::create($from);
@@ -1387,21 +1387,46 @@ 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));
}
// Returning original customer is disabled for better security:
// 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.
/*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 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) {
$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;
}
@@ -1598,8 +1623,12 @@ class FetchEmails extends Command
$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

View File

@@ -2333,7 +2333,7 @@ class Conversation extends Model
);
} else {
// File in remote storage.
$content_stream = $this->getFileStream();
$content_stream = $attachment->getFileStream();
}
$file_info = Attachment::saveFileToDisk($new_attachment, $new_attachment->file_name, $content_stream, $attachment_file);

View File

@@ -1508,7 +1508,7 @@ class Customer extends Model
public function setPhotoFromRemoteFile($url)
{
// Sanitize URL.
try {
/*try {
\Helper::sanitizeRemoteUrl($url, true);
} catch (\Exception $e) {
if ($e->getCode() == \Helper::EXCEPTION_UNSAFE_URL) {
@@ -1517,20 +1517,21 @@ class Customer extends Model
} 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;

View File

@@ -1950,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';
@@ -1972,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();
@@ -1980,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';
@@ -2854,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.
*/
@@ -3395,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');

View File

@@ -142,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>',
]).' ';

View File

@@ -136,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;
@@ -201,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'),

View File

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

View File

@@ -74,6 +74,11 @@ class Helper
*/
public static $memory_cache = [];
/**
* Holds last request data.
*/
public static $last_request = [];
/**
* Files with such extensions are being renamed on upload.
*/
@@ -947,9 +952,10 @@ class Helper
return $dest_path;
}
// Preserved for backward compatibility only.
public static function getPrivateStorage()
{
return \Storage::disk('local');
return \Storage::disk('local_app');
}
public static function getPublicStorage()
@@ -988,34 +994,32 @@ class Helper
/**
* Download remote file and save as file.
*/
public static function downloadRemoteFile($url, $destinationFilePath)
public static function downloadRemoteFile($url, $file_path)
{
// Sanitize URL.
try {
if (!self::sanitizeRemoteUrl($url)) {
throw new \Exception('URL points to the local host', 1);
}
} catch (\Exception $e) {
// getRemoteFileContents() sanitizes remote URL.
$contents = self::getRemoteFileContents($url);
if (!$contents) {
\Helper::logException($e, 'Error downloading a remote file ('.$url.'): ');
return false;
}
$client = new \GuzzleHttp\Client();
try {
$client->request('GET', $url, \Helper::setGuzzleDefaultOptions([
'sink' => $destinationFilePath,
'timeout' => 300, // seconds
'connect_timeout' => 7,
]));
} catch (\Exception $e) {
self::logException($e);
return false;
if (self::isAbasolutePath($file_path)) {
// Absolute path.
\File::put($file_path, $contents);
} else {
// Relative path: store in "storage".
\Storage::put($file_path, $contents);
}
return true;
}
public static function isAbasolutePath($path)
{
return (new \Symfony\Component\Filesystem\Filesystem())->isAbsolutePath($path);
}
/**
* Extract ZIP archive.
* to: must be apsolute path, otherwise extracted into /public/$to.
@@ -1625,8 +1629,24 @@ class Helper
*/
public static function checkPort($host, $port)
{
// Sanitize URL.
self::sanitizeRemoteUrl('https://'.$host, true);
// Sanitize URL and get host IP.
self::sanitizeRemoteUrl('https://'.$host, $throw_exception = true);
// Connect to the address that was actually validated above, not to
// the original hostname (which fsockopen() would otherwise resolve
// again on its own, at a different point in time).
$ips = array_first(self::$last_request['ips'] ?? []);
$host = array_first($ips);
if (!$host) {
// Could not determine host IP.
return false;
}
// fsockopen() requires IPv6 literals to be wrapped in brackets.
if (strpos($host, ':') !== false && strpos($host, '[') === false) {
$host = '['.$host.']';
}
$connection = @fsockopen($host, $port);
if (is_resource($connection)) {
@@ -1774,6 +1794,18 @@ class Helper
return (int)$request->cookie('in_app');
}
/**
* Customers list view mode: cards (default) or table.
* The cookie is set from JS (see setCustomersView() in main.js).
*/
public static function getCustomersView($request = null)
{
if (!$request) {
$request = app('request');
}
return $request->cookie('customers_view') == 'table' ? 'table' : 'cards';
}
/**
* Get identifier for queue:work
*/
@@ -2009,10 +2041,7 @@ class Helper
public static function downloadRemoteFileAsTmp($uri, $follow_redirects = true)
{
try {
// Sanitize URL first.
if (!self::sanitizeRemoteUrl($uri)) {
throw new \Exception('URL points to the local host', 1);
}
// getRemoteFileContents() sanitizes remote URL.
$contents = self::getRemoteFileContents($uri, $follow_redirects);
if (!$contents) {
@@ -2039,18 +2068,20 @@ class Helper
{
try {
// Sanitize URL first.
if (!self::sanitizeRemoteUrl($url)) {
throw new \Exception('URL points to the local host', 1);
$url = self::sanitizeRemoteUrl($url);
if (!$url) {
throw new \Exception('[Helper::getRemoteFileContents()] URL points to the local host', 1);
}
// Quick check for response code 200 or redierct.
$headers = get_headers($url);
// 307 - Temporary Redirect.
if (!preg_match("/(200|301|302|307)/", $headers[0])) {
throw new \Exception('HTTP Status Code: '.$headers[0], 1);
//return false;
}
// Get contents.
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
if ($follow_redirects) {
@@ -2060,7 +2091,9 @@ class Helper
curl_setopt($ch, CURLOPT_URL, $url);
\Helper::setCurlDefaultOptions($ch);
curl_setopt($ch, CURLOPT_TIMEOUT, 180);
$contents = curl_exec($ch);
//$contents = curl_exec($ch);
$contents = self::curlExec($url, $ch);
$curl_errno = curl_errno($ch);
@@ -2089,33 +2122,53 @@ class Helper
}
}
public static function sanitizeRemoteUrl($url, $throw_exception = false, $follow_redirects = true)
public static function sanitizeRemoteUrl($url, $throw_exception = false, $follow_redirects = true, $ch = null)
{
if (!self::checkUrlIpAndHost($url, $throw_exception)) {
self::$last_request = [
'ips' => [],
'response' => '',
'last_url' => '',
];
if (!self::checkUrlHost($url, $throw_exception)) {
return '';
}
// Follow redirects and check all IPs/hosts.
if ($follow_redirects) {
for ($i = 0; $i < 20; $i++) {
$redirected_url = self::curlGetNextRedirectedUrl($url);
$redirected_url = self::curlGetNextRedirect($url, false, $ch);
if ($redirected_url == $url || !$redirected_url) {
break;
}
if ($redirected_url != $url) {
if (!self::checkUrlIpAndHost($redirected_url, $throw_exception)) {
if (!self::checkUrlHost($redirected_url, $throw_exception)) {
return '';
}
}
$url = $redirected_url;
}
}
return $url;
}
// Safe curl exec checking hosts and IPs against SSRF.
public static function curlExec($url, $ch, $throw_exception = false)
{
// Sanitize URL and get response.
$last_url = self::sanitizeRemoteUrl($url, $throw_exception, $follow_redirects = true, $ch);
self::$last_request['last_url'] = $last_url;
$response = self::$last_request['response'] ?? '';
// Clean memory.
unset(self::$last_request['response']);
return $response;
}
/**
* If URL contains IPv6 and port the IPv6 address MUST be in brackets.
* If there is no port specified the function will be able to digest the URL.
@@ -2123,7 +2176,7 @@ class Helper
*
* Returns URL if host can not be extracted.
*/
public static function checkUrlIpAndHost($url, $throw_exception = false)
public static function checkUrlHost($url, $throw_exception = false)
{
$url = self::normalizeIPv6InUrl($url ?? '');
@@ -2174,6 +2227,8 @@ class Helper
$hosts_to_check[] = $host_hex_to_ip;
}
self::$last_request['ips'][$host] = [];
// Check only if host name is passed in URL.
if (!self::isValidIp($host)) {
@@ -2181,6 +2236,10 @@ class Helper
$remote_host_ip = gethostbyname($host);
if ($remote_host_ip && !in_array($remote_host_ip, $hosts_to_check)) {
$hosts_to_check[] = $remote_host_ip;
// Remember IP.
if (!in_array($remote_host_ip, self::$last_request['ips'])) {
self::$last_request['ips'][$host][] = $remote_host_ip;
}
}
// Resolve DNS records.
@@ -2194,12 +2253,24 @@ class Helper
foreach ($dns_records as $dns_record) {
if (!empty($dns_record['ip']) && !in_array($dns_record['ip'], $hosts_to_check)) {
$hosts_to_check[] = $dns_record['ip'];
// Remember IP.
if (!in_array($dns_record['ip'], self::$last_request['ips'])) {
self::$last_request['ips'][$host][] = $dns_record['ip'];
}
}
if (!empty($dns_record['ipv6']) && !in_array($dns_record['ipv6'], $hosts_to_check)) {
$hosts_to_check[] = $dns_record['ipv6'];
// Remember IP.
if (!in_array($dns_record['ipv6'], self::$last_request['ips'])) {
self::$last_request['ips'][$host][] = $dns_record['ipv6'];
}
}
}
}
} else {
// $host contains IP.
// Remember IP.
self::$last_request['ips'][$host][] = $host;
}
foreach ($hosts_to_check as $host_item) {
@@ -2212,6 +2283,10 @@ class Helper
}
}
if (empty(self::$last_request['ips'][$host])) {
self::$last_request['ips'][$host][] = $host;
}
return $url;
}
@@ -2287,20 +2362,23 @@ class Helper
return preg_replace($pattern, '$1[$2]$3', $url);
}
// Get next redicred URL.
public static function curlGetNextRedirectedUrl($url, $throw_exception = false)
// Get next redicred URL and response body.
public static function curlGetNextRedirect($url, $throw_exception = false, $ch = null)
{
$ch = curl_init();
if (!$ch) {
$ch = curl_init();
\Helper::setCurlDefaultOptions($ch);
} else {
//curl_setopt($ch, CURLOPT_TIMEOUT, 180);
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 0);
//curl_setopt($ch, CURLOPT_MAXREDIRS, 1);
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch, CURLOPT_URL, $url);
\Helper::setCurlDefaultOptions($ch);
curl_setopt($ch, CURLOPT_TIMEOUT, 180);
//curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
@@ -2310,6 +2388,10 @@ class Helper
//if ($curl_errno && $curl_errno != CURLE_TOO_MANY_REDIRECTS && !$redirected_url) {
if ($curl_errno) {
// Could not connect to host.
// if ($curl_errno == CURLE_COULDNT_CONNECT) {
// return $url;
// }
if ($throw_exception) {
throw new \Exception('Could not check URL contents by following redirects: '.$curl_errno, 1);
} else {
@@ -2336,6 +2418,11 @@ class Helper
\curl_close($ch);
}
// Remember response body.
if ($ch) {
self::$last_request['response'] = (string)substr($response ?? '', $header_size);
}
return $redirected_url;
}
@@ -2840,7 +2927,7 @@ class Helper
if (\Option::get('send_emails_problem')) {
$flashes[] = [
'type' => 'warning',
'text' => __('There is a problem processing outgoing mail queue — an admin should check :%a_begin%System Status:%a_end% and :%a_begin_recommendations%Recommendations:%a_end%', ['%a_begin%' => '<a href="'.route('system').'#cron" target="_blank">', '%a_end%' => '</a>', /*'%a_begin_logs%' => '<a href="'.route('logs', ['name' => 'send_errors']).'#cron" target="_blank">',*/ '%a_begin_recommendations%' => '<a href="'.config('app.freescout_repo').'/wiki/Background-Jobs" target="_blank">']),
'text' => __('There is a problem processing outgoing mail queue — an admin should check :%a_begin%System Status:%a_end% and :%a_begin_recommendations%Recommendations:%a_end%', ['%a_begin%' => '<a href="'.route('system').'#cron">', '%a_end%' => '</a>', /*'%a_begin_logs%' => '<a href="'.route('logs', ['name' => 'send_errors']).'#cron" target="_blank">',*/ '%a_begin_recommendations%' => '<a href="'.config('app.freescout_repo').'/wiki/Background-Jobs">']),
'unescaped' => true,
];
}

View File

@@ -1448,6 +1448,51 @@ 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)
{

View File

@@ -329,6 +329,11 @@ class Thread extends Model
// 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);
@@ -364,22 +369,28 @@ 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) {
$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;
}
// 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);
@@ -438,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.
*
@@ -453,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.
*

View File

@@ -18,7 +18,7 @@ return [
| or any other location as required by the application or its packages.
*/
'version' => '1.8.243',
'version' => '1.8.244',
/*
|--------------------------------------------------------------------------

View File

@@ -677,7 +677,7 @@ class Header {
$split_address = substr($split_address, 0, -1);
}
if (preg_match(
'/^(?:(?P<name>.+)\s)?(?(name)<|<?)(?P<email>[^\s]+?)(?(name)>|>?)$/',
'/^(?:(?P<name>.+)\s*)?(?(name)<|<?)(?P<email>[^\s]+?)(?(name)>|>?)$/',
$split_address,
$matches
)) {
@@ -722,7 +722,7 @@ class Header {
if (is_string($list)) {
// $list = "<noreply@github.com>"
if (preg_match(
'/^(?:(?P<name>.+)\s)?(?(name)<|<?)(?P<email>[^\s]+?)(?(name)>|>?)$/',
'/^(?:(?P<name>.+)\s*)?(?(name)<|<?)(?P<email>[^\s]+?)(?(name)>|>?)$/',
$list,
$matches
)) {

View File

@@ -443,7 +443,11 @@ class Query {
}
}
if ($last_exception) {
// One malformed message must not prevent the other messages in this
// batch from reaching FreeScout. If none could be parsed, preserve the
// fetch error as well as the unread flags so the failure stays visible.
// https://github.com/freescout-help-desk/freescout/pull/5668
if ($last_exception && $messages->isEmpty()) {
throw $last_exception;
}

162
public/css/style.css vendored
View File

@@ -730,7 +730,7 @@ select[multiple].input-md {
* Cards
*/
.card {
border: 1px solid #dedede;
border: 1px solid #e1e5e8;
box-sizing: border-box;
display: block;
position: relative;
@@ -739,8 +739,8 @@ select[multiple].input-md {
width: 270px;
height: 82px;
float: left;
-webkit-box-shadow:0 0 5px rgba(0,0,0,.2);
box-shadow:0 0 5px rgba(0,0,0,.2);
border-radius: 8px;
box-shadow: 0 1px 2px rgba(23, 43, 58, .06), 0 1px 8px rgba(23, 43, 58, .05);
}
a.card:active,
a.card:focus,
@@ -877,13 +877,9 @@ a h4 {
.dash-card {
float: left;
position: relative;
border: 1px solid #dedede;
border: 1px solid #e1e5e8;
border-radius: 15px;
/*border-left: 4px solid #5cb85c;*/
/*-webkit-box-shadow: 0 1px 7px 0 rgba(0,0,0,.08);
box-shadow: 0 1px 7px 0 rgba(0,0,0,.08);*/
-webkit-box-shadow:0 0 5px rgba(0,0,0,.2);
box-shadow:0 0 5px rgba(0,0,0,.2);
box-shadow: 0 1px 2px rgba(23, 43, 58, .06), 0 1px 8px rgba(23, 43, 58, .05);
box-sizing: border-box;
min-height: 256px;
margin: 0 20px 20px 0;
@@ -2462,6 +2458,35 @@ button.conv-checkbox-clear {
.thread-content img {
max-width: 100%;
}
/* Keep aspect ratio when max-width shrinks an image with width/height attributes */
.thread-content img[width] {
height: auto;
}
/* Fit wide tables (e.g. from Outlook) into the thread instead of scrolling it horizontally.
overflow-wrap: anywhere lets columns shrink below the longest word when there is no room.
Cell widths are reset as Outlook sets arbitrary percentages (like 1.7% for a column with long text),
which would squeeze such columns to one letter per line. */
.thread-content table {
max-width: 100%;
}
.thread-content td,
.thread-content th {
overflow-wrap: anywhere;
width: auto !important;
}
/* On phones there is no room to fit a table without breaking every word,
so the table is scrolled horizontally on its own instead of the whole thread. */
@media (max-width:700px) {
.thread-content table {
display: block;
overflow-x: auto;
width: max-content !important;
}
.thread-content td,
.thread-content th {
overflow-wrap: normal;
}
}
.conv-new #conv-toolbar {
border-bottom: 1px solid #d4d9dd;
padding: 6px 0 7px 0;
@@ -3042,15 +3067,9 @@ ul.sidebar-block-list {
transform: translate(-50%,0) translate(-1px,0);
z-index: 1099;
max-width: 300px;
border-radius: 0;
flex-direction: row;
padding: 9px 14px;
/*-webkit-box-shadow: 4px 4px 12px rgba(0, 0, 0, .175);
box-shadow: 4px 4px 12px rgba(0, 0, 0, .175);*/
-webkit-box-shadow:0 0 5px rgba(0,0,0,.4);
box-shadow:0 0 5px rgba(0,0,0,.4);
cursor: default;
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
@@ -3064,7 +3083,14 @@ ul.sidebar-block-list {
-webkit-hyphens: auto;
hyphens: auto;
border: 0;
border-top: 4px solid #009272;
border-radius: 4px;
box-shadow: 0 0 22px rgba(18, 51, 76, .1);
}
.alert-floating.alert-danger { border-top-color: #e11d48; }
.alert-floating.alert-warning { border-top-color: #d97706; }
.alert-floating.alert-info { border-top-color: #4f46e5; }
.alert-floating.alert-danger,
.alert-floating.alert-warning {
position: fixed;
@@ -3080,6 +3106,10 @@ ul.sidebar-block-list {
color: #2a3b47;
text-decoration: underline;
}
.alert-floating .glyphicon {
font-size: 13px;
vertical-align: inherit;
}
.alert-floating.alert-success .glyphicon {
color: #53b961;
}
@@ -3526,14 +3556,14 @@ ul.sidebar-block-list {
*/
.module-card {
background-color: #ffffff;
border: 1px solid #dedede;
border: 1px solid #e1e5e8;
border-radius: 8px;
box-sizing: border-box;
display: block;
position: relative;
margin: 0 18px 18px 0;
padding: 18px;
-webkit-box-shadow:0 0 5px rgba(0,0,0,.2);
box-shadow:0 0 5px rgba(0,0,0,.2);
box-shadow: 0 1px 2px rgba(23, 43, 58, .06), 0 1px 8px rgba(23, 43, 58, .05);
}
.module-card.active {
background-color: #deffde;
@@ -3647,6 +3677,75 @@ ul.sidebar-block-list {
.customers-pager a:hover {
color: #333;
}
/* Customers list: table view */
.customers-view-switch {
color: #93a1af;
cursor: pointer;
margin-left: 6px;
font-size: 13px;
top: 2px;
}
.customers-view-switch:hover {
color: #333;
}
.customers-table {
background-color: #fff;
/* Fixed layout: long values can not stretch the table, they are truncated. */
table-layout: fixed;
width: 100%;
}
.customers-table-col-name {
width: 34%;
}
.customers-table-col-email {
width: 30%;
}
.customers-table-col-phone {
width: 16%;
}
.customers-table-col-company {
width: 20%;
}
.customers-table > thead > tr > th,
.customers-table > tbody > tr > td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.customers-table > thead > tr > th {
color: #93a1af;
font-weight: normal;
border-bottom-width: 1px;
}
.customers-table > tbody > tr > td {
vertical-align: middle;
}
.customers-table-name > a {
color: #2a3b47;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.customers-table-photo {
width: 28px;
height: 28px;
margin-right: 6px;
border-radius: 50%;
object-fit: cover;
}
@media (max-width:582px) {
.customers-table > thead > tr > th:nth-child(n+3),
.customers-table > tbody > tr > td:nth-child(n+3) {
display: none;
}
.customers-table-col-name {
width: 55%;
}
.customers-table-col-email {
width: 45%;
}
}
/*.customers-pager a:hover,
.customers-pager a:active
.customers-pager a:focus, {
@@ -3655,7 +3754,7 @@ ul.sidebar-block-list {
}*/
/**
* Panel
* Panels
*/
.panel-sortable .handle {
cursor: move;
@@ -3687,6 +3786,15 @@ ul.sidebar-block-list {
background-color: #f8f9f9;
border-color: #dde2e6;
}
.panel-group .panel {
border-radius: 8px;
box-shadow: 0 1px 2px rgba(23, 43, 58, .06), 0 1px 8px rgba(23, 43, 58, .05);
border: 1px solid #e1e5e8;
}
.panel-heading {
border-bottom: 1px solid #e1e5e8;
border-radius: 8px 8px 0 0;
}
/**
* Print version
@@ -4156,6 +4264,16 @@ a.help-icon:hover {
.text-larger {
font-size: 16.8px;
}
.text-link,
.text-link:focus {
color: #0078D7!important;
text-decoration: none!important;
cursor: pointer;
}
.text-link:hover {
color: #000!important;
text-decoration: none!important;
}
#loader-main {
position: fixed;
display: none;
@@ -4294,8 +4412,7 @@ a.disabled:focus {
left: -5px;
}
.hover-shade:hover {
-webkit-box-shadow:0 0 13px rgba(0,0,0,.2);
box-shadow:0 0 13px rgba(0,0,0,.2);
box-shadow:0 0 5px rgba(0,0,0,.2);
}
.dropdown-menu > li > span {
display: block;
@@ -4629,6 +4746,3 @@ a.disabled:focus {
.btn {
border-radius: 5px;
}
.panel-group .panel {
border-radius: 5px;
}

118
public/js/main.js vendored
View File

@@ -25,6 +25,8 @@ var fs_checkbox_shift_last_checked = null;
var upload_in_progress = false;
var audio_chat;
var autoplay_msg_shown = false;
// Remember recipients rendered by the server to restore them after discarding a draft.
var fs_reply_recipients = null;
var FS_STATUS_CLOSED = 3;
@@ -421,6 +423,19 @@ $(document).ready(function(){
e.preventDefault();
});
// Icons acting as buttons (role="button" on span/i): Enter and Space click them, like a real button.
// Space on a dropdown toggle is left to Bootstrap, which already opens it.
$(document).on('keydown', 'span[role="button"][tabindex], i[role="button"][tabindex]', function(e) {
if ((e.which != 13 && e.which != 32) || (e.originalEvent && e.originalEvent.repeat)) {
return;
}
if (e.which == 32 && $(this).is('[data-toggle="dropdown"]')) {
return;
}
e.preventDefault();
$(this).click();
});
//applyVoidLinks();
$('ul.customer-contacts a.contact-main').click(function(e) {
copyToClipboard($(this).text());
@@ -1463,7 +1478,7 @@ function initConversation()
starConversationInit();
maybeShowStoredNote();
maybeShowDraft();
processLinks();
//processLinks();
initConvSettings();
// Show reply form in chat mode
@@ -2324,6 +2339,8 @@ function initReplyForm(load_attachments, init_customer_selector, is_new_conv)
{
$(document).ready(function() {
rememberReplyRecipients();
convEditorInit();
if (typeof(load_attachments) != "undefined") {
loadAttachments();
@@ -2938,6 +2955,7 @@ function searchInit()
starConversationInit();
customersPagination();
customersViewSwitchInit();
$(".sidebar-menu .menu-link a").filter('[data-filter]').click(function(e){
var trigger = $(this);
@@ -3119,6 +3137,44 @@ function conversationPagination()
});
}
// Customers list view: cards (default) or table.
// The choice is stored in the "customers_view" cookie and used by the server
// when rendering the list (Helper::getCustomersView()).
function setCustomersView(view)
{
view = (view == 'table') ? 'table' : 'cards';
// SameSite=Lax and no Secure flag, so the cookie works on HTTP installations too.
setCookie('customers_view', view, {samesite: 'Lax'});
updateCustomersViewSwitch(view);
loadCustomers();
}
function updateCustomersViewSwitch(view)
{
$('.customers-view-switch').each(function() {
var button = $(this);
var is_table = (view == 'table');
var title = is_table ? button.attr('data-title-cards') : button.attr('data-title-table');
// Icon shows the view the user will switch to.
button.toggleClass('glyphicon-th-large', is_table)
.toggleClass('glyphicon-th-list', !is_table)
.attr('title', title)
.attr('data-original-title', title);
});
}
function customersViewSwitchInit()
{
$('.customers-view-switch').click(function(e) {
setCustomersView(getCookie('customers_view') == 'table' ? 'cards' : 'table');
// The switch is inside the tab link.
e.preventDefault();
e.stopPropagation();
});
}
function customersPagination()
{
$(".table-customers .pager-nav").click(function(e){
@@ -3581,6 +3637,19 @@ function showModalDialog(body, options)
}
options = Object.assign(standard_options, options);
// Focus the primary button, so Enter confirms and Tab moves to Cancel.
// Skipped when the dialog has a field to fill in.
var on_show = options.on_show;
options.on_show = function(modal, a) {
if (typeof(on_show) == "function") {
on_show(modal, a);
}
var modal_body = modal.children().find('.modal-body:first');
if (!modal_body.find('input:visible,textarea:visible,select:visible').length) {
modal_body.find('.btn-primary:enabled:visible:first').focus();
}
};
triggerModal(null, options);
}
@@ -4834,11 +4903,13 @@ function discardDraft(thread_id)
} else {
// Hide editor
hideReplyEditor();
$("#to").val(
// https://github.com/freescout-help-desk/freescout/pull/5674
restoreReplyRecipients();
/*$("#to").val(
$("#to option:first").val()
);
$(".conv-reply-block :input[name='cc']:first").val('');
$(".conv-reply-block :input[name='bcc']:first").val('');
);*/
//$(".conv-reply-block :input[name='cc[]']:first").val('');
//$(".conv-reply-block :input[name='bcc[]']:first").val('');
setReplyBody('');
$('#conv-subject').removeClass('action-visible');
}
@@ -4972,6 +5043,43 @@ function threadHideOriginal(trigger)
trigger.addClass('hidden');
}
function rememberReplyRecipients()
{
if (fs_reply_recipients !== null || !getGlobalAttr('conversation_id')) {
return;
}
fs_reply_recipients = {
to: $('#to').val(),
cc: $('#cc').val() || [],
bcc: $('#bcc').val() || []
};
}
function restoreReplyRecipients()
{
if (fs_reply_recipients === null) {
$("#to").val($("#to option:first").val());
return;
}
if (fs_reply_recipients.to) {
$('#to').val(fs_reply_recipients.to);
}
var fields = ['cc', 'bcc'];
for (var f in fields) {
var select = $('#'+fields[f]);
if (!select.length) {
continue;
}
cleanSelect2(select);
var emails = fs_reply_recipients[fields[f]];
for (var i in emails) {
addSelect2Option(select, {
id: emails[i], text: emails[i]
});
}
}
}
function hideReplyEditor()
{
$(".conv-action-block").addClass('hidden');

View File

@@ -2993,6 +2993,127 @@ var Bullet = /** @class */ (function () {
});
rng.select();
};
/**
* Split <div>/<p> blocks made of <br>-separated lines into one <div> per
* line and narrow the range to the lines touched by the selection, so
* that only those lines are turned into list items.
* Blocks without direct <br> children are left untouched.
*
* @param {WrappedRange} rng
* @return {WrappedRange}
*/
Bullet.prototype.splitBrParas = function (rng) {
var sc = rng.sc, so = rng.so, ec = rng.ec, eo = rng.eo;
var changed = false;
var isBr = function (node) {
return !!node && node.nodeName === 'BR';
};
var paras = rng.nodes(dom.isPara, { includeAncestor: true }).filter(function (para) {
if (!/^(DIV|P)$/.test(para.nodeName) || !$$1(para).children('br').length) {
return false;
}
// Skip blocks containing other blocks or lists.
return !$$1(para).children().filter(function () {
return dom.isPara(this) || dom.isList(this);
}).length;
});
$$1.each(paras, function (idx, para) {
var children = $$1.makeArray(para.childNodes);
// Line index of each direct child (a <br> belongs to the line it ends).
var childLine = [];
var line = 0;
$$1.each(children, function (i, child) {
childLine[i] = line;
if (isBr(child)) {
line++;
}
});
// A trailing <br> does not start a new visible line.
var lineCount = isBr(children[children.length - 1]) ? line : line + 1;
// Line a boundary point falls on, null if the point is outside para.
var lineAt = function (node, offset, isEnd) {
if (node !== para && !$$1.contains(para, node)) {
return null;
}
var i;
if (node === para) {
if (isEnd && offset > 0 && isBr(children[offset - 1])) {
offset--; // range ends right after a <br>
}
i = Math.min(offset, children.length - 1);
}
else {
var c = node;
while (c.parentNode !== para) {
c = c.parentNode;
}
i = $$1.inArray(c, children);
// Range ends at the very start of a later line: exclude it.
if (isEnd && c === node && offset === 0 && i > 0 && isBr(children[i - 1])) {
i--;
}
}
return Math.min(childLine[i], lineCount - 1);
};
var startLine = lineAt(sc, so, false);
var endLine = lineAt(ec, eo, true);
var startInside = startLine !== null;
var endInside = endLine !== null;
if (!startInside) {
startLine = 0;
}
if (!endInside) {
endLine = lineCount - 1;
}
if (endLine < startLine) {
endLine = startLine;
}
// Build one <div> per line.
var divs = [];
var cur = null;
var closeLine = function () {
if (!cur.childNodes.length) {
cur.appendChild(document.createElement('br')); // keep blank lines
}
divs.push(cur);
cur = null;
};
$$1.each(children, function (i, child) {
if (!cur) {
cur = document.createElement('div');
if (para.className) {
cur.className = para.className;
}
if (para.style && para.style.cssText) {
cur.style.cssText = para.style.cssText;
}
}
if (isBr(child)) {
closeLine();
}
else {
cur.appendChild(child);
}
});
if (cur && cur.childNodes.length) {
closeLine();
}
$$1.each(divs, function (i, d) {
para.parentNode.insertBefore(d, para);
});
para.parentNode.removeChild(para);
changed = true;
if (startInside) {
sc = divs[startLine];
so = 0;
}
if (endInside) {
ec = divs[endLine];
eo = dom.nodeLength(divs[endLine]);
}
});
return changed ? range.create(sc, so, ec, eo) : rng;
};
/**
* toggle list
*
@@ -3001,6 +3122,8 @@ var Bullet = /** @class */ (function () {
Bullet.prototype.toggleList = function (listName, editable) {
var _this = this;
var rng = range.create(editable).wrapBodyInlineWithPara();
// Split <br>-separated lines so only the selected lines become list items.
rng = this.splitBrParas(rng);
var paras = rng.nodes(dom.isPara, { includeAncestor: true });
var bookmark = rng.paraBookmark(paras);
var clustereds = lists.clusterBy(paras, func.peq2('parentNode'));

View File

@@ -788,7 +788,7 @@
"Your password must be at least 8 characters": "A palavra-passe deve ter pelo menos 8 caracteres",
"ZIP": "Código Postal",
"active": "ativo",
"add": "Adicionar",
"add": "adicionar",
"all": "todos",
"anyone": "qualquer um",
"are forwarding": "são encaminhados",

View File

@@ -167,7 +167,7 @@
</td>
@endif
<td class="conv-attachment">
<i class="glyphicon conv-star @if ($conversation->isStarredByUser()) glyphicon-star @else glyphicon-star-empty @endif" title="@if ($conversation->isStarredByUser()){{ __("Unstar Conversation") }}@else{{ __("Star Conversation") }}@endif"></i>
<i role="button" tabindex="0" class="glyphicon conv-star @if ($conversation->isStarredByUser()) glyphicon-star @else glyphicon-star-empty @endif" title="@if ($conversation->isStarredByUser()){{ __("Unstar Conversation") }}@else{{ __("Star Conversation") }}@endif"></i>
@if ($conversation->has_attachments)
<i class="glyphicon glyphicon-paperclip"></i>
@@ -194,7 +194,7 @@
</a>
</td>
<td class="conv-thread-count">
<i class="glyphicon conv-star @if ($conversation->isStarredByUser()) glyphicon-star @else glyphicon-star-empty @endif" title="@if ($conversation->isStarredByUser()){{ __("Unstar Conversation") }}@else{{ __("Star Conversation") }}@endif"></i>
<i role="button" tabindex="0" class="glyphicon conv-star @if ($conversation->isStarredByUser()) glyphicon-star @else glyphicon-star-empty @endif" title="@if ($conversation->isStarredByUser()){{ __("Unstar Conversation") }}@else{{ __("Star Conversation") }}@endif"></i>
{{--<a href="{{ $conversation->url() }}" title="{{ __('View conversation') }}">@if ($conversation->threads_count <= 1)&nbsp;@else<span>{{ $conversation->threads_count }}</span>@endif</a>--}}
</td>

View File

@@ -14,7 +14,7 @@
<ul class="sidebar-block-list">
@foreach ($prev_conversations as $prev_conversation)
<li>
<a href="{{ $prev_conversation->url() }}" target="_blank" class="help-link"><i class="glyphicon @if ($prev_conversation->isPhone()) glyphicon-earphone @else glyphicon-envelope @endif"></i>{{ $prev_conversation->getSubject() }}</a>
<a href="{{ $prev_conversation->url() }}" class="help-link"><i class="glyphicon @if ($prev_conversation->isPhone()) glyphicon-earphone @else glyphicon-envelope @endif"></i>{{ $prev_conversation->getSubject() }}</a>
</li>
@endforeach
</ul>

View File

@@ -12,7 +12,7 @@
@action('thread.after_header', $thread, $loop, $threads, $conversation, $mailbox)
</div>
<div class="dropdown thread-options">
<span class="dropdown-toggle {{--glyphicon glyphicon-option-vertical--}}" data-toggle="dropdown" role="button" aria-expanded="false" aria-haspopup="true"><b class="caret"></b></span>
<span class="dropdown-toggle {{--glyphicon glyphicon-option-vertical--}}" data-toggle="dropdown" role="button" @if (Auth::user()->isAdmin())tabindex="0" @endif aria-expanded="false" aria-haspopup="true"><b class="caret"></b></span>
@if (Auth::user()->isAdmin())
<ul class="dropdown-menu dropdown-menu-right" role="menu">
@action('thread.menu', $thread)
@@ -313,7 +313,7 @@
</div>
</div>
<div class="dropdown thread-options">
<span class="dropdown-toggle {{--glyphicon glyphicon-option-vertical--}}" data-toggle="dropdown" role="button" aria-expanded="false" aria-haspopup="true"><b class="caret"></b></span>
<span class="dropdown-toggle {{--glyphicon glyphicon-option-vertical--}}" data-toggle="dropdown" role="button" tabindex="0" aria-expanded="false" aria-haspopup="true"><b class="caret"></b></span>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
@if (Auth::user()->can('edit', $thread))
<li><a href="#" title="" class="thread-edit-trigger" role="button">{{ __("Edit") }}</a></li>

View File

@@ -157,10 +157,10 @@
@if (Eventy::filter('search.is_tab_visible', true, App\Conversation::SEARCH_MODE_CONV))
<li @if ($mode == App\Conversation::SEARCH_MODE_CONV)class="active search-tab-conv"@endif><a href="{{ \Helper::fixProtocol(request()->fullUrlWithQuery(['mode' => App\Conversation::SEARCH_MODE_CONV])) }}">{{ __('Conversations') }} <b>({{ $conversations->total() }})</b>@action('search.conversations_tab_append', $filters, $conversations->total())</a></li>
@endif
<li @if ($mode == App\Conversation::SEARCH_MODE_CUSTOMERS)class="active"@endif><a href="{{ \Helper::fixProtocol(request()->fullUrlWithQuery(['mode' => App\Conversation::SEARCH_MODE_CUSTOMERS])) }}">{{ __('Customers') }} <b>({{ $customers->total() }})</b></a></li>
<li @if ($mode == App\Conversation::SEARCH_MODE_CUSTOMERS)class="active"@endif><a href="@if ($mode == App\Conversation::SEARCH_MODE_CUSTOMERS)#@else{{ \Helper::fixProtocol(request()->fullUrlWithQuery(['mode' => App\Conversation::SEARCH_MODE_CUSTOMERS])) }}@endif">{{ __('Customers') }} <b>({{ $customers->total() }})</b>@if ($mode == App\Conversation::SEARCH_MODE_CUSTOMERS) <i class="text-link glyphicon @if (\Helper::getCustomersView() == 'table') glyphicon-th-large @else glyphicon-th-list @endif customers-view-switch" role="button" data-title-table="{{ __('Table View') }}" data-title-cards="{{ __('Cards View') }}" title="@if (\Helper::getCustomersView() == 'table'){{ __('Cards View') }}@else{{ __('Table View') }}@endif" data-toggle="tooltip"></i>@endif</a></li>
</ul>
@if ($mode == App\Conversation::SEARCH_MODE_CONV)
@include('conversations/conversations_table', ['mailbox' => $search_mailbox, 'params' => ['target_blank' => true, 'show_mailbox' => (count(Auth::user()->mailboxesCanView(true)) > 1)]])
@include('conversations/conversations_table', ['mailbox' => $search_mailbox, 'params' => ['target_blank' => false, 'show_mailbox' => (count(Auth::user()->mailboxesCanView(true)) > 1)]])
@else
@include('customers/partials/customers_table')
@endif

View File

@@ -42,7 +42,8 @@
data-placement="bottom"
title="{{ $action['label'] }}"
aria-label="{{ $action['label'] }}"
role="button"></span>
role="button"
tabindex="0"></span>
@elseif (!empty($action['url']))
{{-- Action with URL (like move, merge) --}}
<a href="{{ $action['url']($conversation) }}"
@@ -69,6 +70,7 @@
title="{{ $action['label'] }}"
aria-label="{{ $action['label'] }}"
role="button"
tabindex="0"
@if (!empty($action['attrs']))
@foreach ($action['attrs'] as $attr_key => $attr_value)
{{ $attr_key }}="{{ $attr_value }}"
@@ -85,6 +87,7 @@
<span class="conv-action glyphicon glyphicon-option-horizontal dropdown-toggle"
data-toggle="dropdown"
role="button"
tabindex="0"
aria-haspopup="true"
aria-expanded="false"
aria-label="{{ __('More Actions') }}"></span>
@@ -206,7 +209,7 @@
@endif
@action('conversation.after_subject', $conversation, $mailbox)
<div class="conv-numnav">
<i class="glyphicon conv-star @if ($conversation->isStarredByUser()) glyphicon-star @else glyphicon-star-empty @endif" title="@if ($conversation->isStarredByUser()){{ __("Unstar Conversation") }}@else{{ __("Star Conversation") }}@endif"></i>&nbsp; # <strong>{{ $conversation->number }}</strong>
<i role="button" tabindex="0" class="glyphicon conv-star @if ($conversation->isStarredByUser()) glyphicon-star @else glyphicon-star-empty @endif" title="@if ($conversation->isStarredByUser()){{ __("Unstar Conversation") }}@else{{ __("Star Conversation") }}@endif"></i>&nbsp; # <strong>{{ $conversation->number }}</strong>
</div>
<div id="conv-viewers">
@foreach ($viewers as $viewer)

View File

@@ -1,15 +1,40 @@
@if (count($customers))
<div class="table-customers" data-page="{{ (int)request()->get('page', 1) }}">
<div class="container">
<div class="card-list margin-top">
@foreach ($customers as $customer)
<a href="{{ Eventy::filter('customer.card.url', route('customers.update', ['id' => $customer->id]), $customer) }}" class="card hover-shade" @action('customer.card.link', $customer) >
<img src="{{ $customer->getPhotoUrl() }}" />
<h4 title="{{ $customer->first_name }} {{ $customer->last_name }}">{{ $customer->first_name }} {{ $customer->last_name }}</h4>
<p class="text-truncate"><small>{{ $customer->getEmailOrPhone() }}</small></p>
</a>
@endforeach
</div>
@if (\Helper::getCustomersView() == 'table')
<table class="table table-striped table-hover customers-table margin-top">
<thead>
<tr>
<th class="customers-table-col-name">{{ __('Name') }}</th>
<th class="customers-table-col-email">{{ __('Email') }}</th>
<th class="customers-table-col-phone">{{ __('Phone') }}</th>
<th class="customers-table-col-company">{{ __('Company') }}</th>
</tr>
</thead>
<tbody>
@foreach ($customers as $customer)
<tr>
<td class="customers-table-name">
<a href="{{ Eventy::filter('customer.card.url', route('customers.update', ['id' => $customer->id]), $customer) }}" @action('customer.card.link', $customer) title="{{ $customer->first_name }} {{ $customer->last_name }}"><img src="{{ $customer->getPhotoUrl() }}" class="customers-table-photo" /> {{ $customer->first_name }} {{ $customer->last_name }}</a>
</td>
<td title="{{ $customer->getMainEmail() }}">{{ $customer->getMainEmail() }}</td>
<td title="{{ $customer->getMainPhoneNumber() }}">{{ $customer->getMainPhoneNumber() }}</td>
<td title="{{ $customer->company }}">{{ $customer->company }}</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div class="card-list margin-top">
@foreach ($customers as $customer)
<a href="{{ Eventy::filter('customer.card.url', route('customers.update', ['id' => $customer->id]), $customer) }}" class="card hover-shade" @action('customer.card.link', $customer) >
<img src="{{ $customer->getPhotoUrl() }}" />
<h4 title="{{ $customer->first_name }} {{ $customer->last_name }}">{{ $customer->first_name }} {{ $customer->last_name }}</h4>
<p class="text-truncate"><small>{{ $customer->getEmailOrPhone() }}</small></p>
</a>
@endforeach
</div>
@endif
</div>
@if ($customers->lastPage() > 1)

View File

@@ -4,6 +4,7 @@
<meta name="viewport" content="width=350px, user-scalable=yes">
<style>
#wrapper * { max-width: 650px !important; }
#wrapper img { max-width: 100% !important; height: auto !important; }
p { margin:0 0 1.5em 0; }
pre { font-family: Menlo, Monaco, monospace, sans-serif; padding: 0 0 1.6em 0; color:#333333; line-height:15px; }
a { color:#3f8abf; text-decoration:none; }
@@ -156,7 +157,7 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0" bgcolor="#f8f9fa">
<tr>
@if ($is_rtl)
<td valign="top">
<td valign="top" align="left" width="1%" nowrap="nowrap" style="white-space:nowrap; padding-right:10px;">
<div style="font-family:Arial, 'Helvetica Neue', Helvetica, Tahoma, sans-serif; color:#b5b9bd; font-size:12px; line-height:16px; margin:0;" align="left">{{ App\User::dateFormat($thread->created_at, 'M j, H:i', $user) }}</div>
</td>
<td valign="top">
@@ -170,8 +171,8 @@
{!! safe_raw_html($thread->getActionText('', true, false, $user, htmlspecialchars(view('emails/user/thread_by', ['thread' => $thread, 'user' => $user])->render()))) !!}
</div>
</td>
<td valign="top">
<div style="font-family:Arial, 'Helvetica Neue', Helvetica, Tahoma, sans-serif; color:#b5b9bd; font-size:12px; line-height:16px; margin:0;">{{ App\User::dateFormat($thread->created_at, 'M j, H:i', $user) }}</div>
<td valign="top" align="right" width="1%" nowrap="nowrap" style="white-space:nowrap; padding-left:10px;">
<div style="font-family:Arial, 'Helvetica Neue', Helvetica, Tahoma, sans-serif; color:#b5b9bd; font-size:12px; line-height:16px; margin:0;" align="right">{{ App\User::dateFormat($thread->created_at, 'M j, H:i', $user) }}</div>
</td>
@endif
</tr>
@@ -192,7 +193,7 @@
<tr>
@if ($is_rtl)
{{-- RTL Order: User name on the right, Date on the left --}}
<td valign="top">
<td valign="top" align="left" width="1%" nowrap="nowrap" style="white-space:nowrap; padding-right:10px;">
<div style="font-family:Arial, 'Helvetica Neue', Helvetica, Tahoma, sans-serif; color:#B5B9BD; font-size:12px; line-height:18px; margin:0;" align="left">{{ App\User::dateFormat($thread->created_at, 'M j, H:i', $user) }}</div>
</td>
<td>
@@ -207,7 +208,7 @@
@include('emails.user._notification_thread_action', ['thread' => $thread])
</h3>
</td>
<td valign="top">
<td valign="top" align="right" width="1%" nowrap="nowrap" style="white-space:nowrap; padding-left:10px;">
<div style="font-family:Arial, 'Helvetica Neue', Helvetica, Tahoma, sans-serif; color:#B5B9BD; font-size:12px; line-height:18px; margin:0;" align="right">{{ App\User::dateFormat($thread->created_at, 'M j, H:i', $user) }}</div>
</td>
@endif
@@ -227,7 +228,7 @@
@endif
@action('email_notification.before_body', $thread, $user, $conversation)
<div style="font-family:Arial, 'Helvetica Neue', Helvetica, Tahoma, sans-serif; color:#444; font-size:14px; line-height:20px; margin:0; @if ($is_rtl) text-align: right; direction: rtl; unicode-bidi: plaintext; @endif">
{!! safe_raw_html($thread->getCleanBody() ?? '') !!}
{!! safe_raw_html(\MailHelper::fitImages($thread->getCleanBody() ?? '', 580)) !!}
</div>
@if ($thread->has_attachments)

View File

@@ -210,7 +210,7 @@
<ul class="dropdown-menu dropdown-with-icons">
<li>
<form class="form-inline form-nav-search" role="form" action="{{ route('conversations.search') }}" target="_blank">
<form class="form-inline form-nav-search" role="form" action="{{ route('conversations.search') }}">
<div class="input-group">
<input type="text" class="form-control" name="q">
<span class="input-group-btn">

View File

@@ -4,7 +4,7 @@
$menu_mailboxes = $auth_user->mailboxesCanView();
@endphp
@action('mailbox.update.before_mailbox_name', $mailbox)
<span class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false" aria-haspopup="true" v-pre>
<span class="dropdown-toggle" data-toggle="dropdown" role="button" @if (count($menu_mailboxes))tabindex="0" @endif aria-expanded="false" aria-haspopup="true" v-pre>
@if ($mailbox->isArchived())<i class="glyphicon glyphicon-lock smaller"></i> @elseif (!$mailbox->isConnected())<i class="glyphicon glyphicon-flash smaller"></i> @endif{{ $mailbox->name }} @if (count($menu_mailboxes))<span class="caret"></span>@endif
</span>
@if (count($menu_mailboxes))

View File

@@ -17,7 +17,7 @@
<span class="section-heading-right">
<a href="#" data-trigger="modal" data-modal-body="#deactivate_license_modal" data-modal-size="sm" data-modal-no-footer="true" data-modal-title="{{ __('Deactivate License') }}" data-modal-on-show="deactivateLicenseModal" class="small">{{ __('Deactivate License') }}</a> |
<a href="https://freescout.net/remind-license-keys/" target="_blank" class="small">{{ __('Remind License Keys') }}</a>
<a href="{{ config('app.freescout_url') }}/remind-license-keys/" target="_blank" class="small">{{ __('Remind License Keys') }}</a>
</span>
</div>

View File

@@ -57,7 +57,7 @@
@elseif ($col == 'date')
{{ App\User::dateFormat(new Illuminate\Support\Carbon($row[$col]), 'M j, H:i:s') }}
@elseif (is_object($row[$col]) && get_class($row[$col]) == 'App\Thread')
<a href="{{ route('conversations.view', ['id' => $row[$col]->conversation_id]) }}#thread-{{ $row[$col]->id }}" target="_blank">#{{ $row[$col]->conversation->number }}</a>
<a href="{{ route('conversations.view', ['id' => $row[$col]->conversation_id]) }}#thread-{{ $row[$col]->id }}">#{{ $row[$col]->conversation->number }}</a>
@else
{{ $row[$col] }}
@endif

View File

@@ -52,6 +52,22 @@
</div>
</div>
<div class="form-group{{ $errors->has('settings[limit_user_customer_visibility]') ? ' has-error' : '' }}">
<label for="limit_user_customer_visibility_0" class="col-sm-2 control-label">{{ __('Customer Visibility') }}</label>
<div class="col-sm-6">
<p class="form-help">
{{ __('Which customers non-admin users can see.') }}
</p>
<div class="controls">
<label for="limit_user_customer_visibility_0" class="radio inline plain"><input type="radio" name="settings[limit_user_customer_visibility]" value="false" id="limit_user_customer_visibility_0" @if (!$settings['limit_user_customer_visibility'])checked="checked"@endif> {{ __('All customers') }}</label>
<label for="limit_user_customer_visibility_1" class="radio inline"><input type="radio" name="settings[limit_user_customer_visibility]" value="true" id="limit_user_customer_visibility_1" @if ($settings['limit_user_customer_visibility'])checked="checked"@endif> {{ __('Only customers from accessible mailboxes') }}</label>
</div>
@include('partials/field_error', ['field'=>'settings.limit_user_customer_visibility'])
</div>
</div>
<div class="form-group{{ $errors->has('settings[locale]') ? ' has-error' : '' }}">
<label for="locale" class="col-sm-2 control-label">{{ __('Default Language') }}</label>

View File

@@ -1,5 +1,5 @@
<div class="dropdown sidebar-title">
<span class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false" aria-haspopup="true" v-pre>
<span class="dropdown-toggle" data-toggle="dropdown" role="button" @if (isset($users) && count($users))tabindex="0" @endif aria-expanded="false" aria-haspopup="true" v-pre>
{{ $user->first_name }} {{ $user->last_name }} @if (isset($users) && count($users))<span class="caret"></span>@endif
</span>
@if (isset($users) && count($users))

View File

@@ -12,7 +12,7 @@
</th>
<td class="text-center">{{ __('Email') }}<br/><input type="checkbox" class="sel-all" value="email"></td>
<td class="text-center">{{ __('Browser') }}<br/><input type="checkbox" class="sel-all" value="browser"></td>
<td class="text-center">{{ __('Mobile') }}<br/><small>(<a href="https://freescout.net/module/mobile-notifications/" target="_blank">Android</a> / <a href="https://freescout.net/module/mobile-notifications/" target="_blank">iOS</a>)</small><br/><input type="checkbox" class="sel-all" @if (!$mobile_available) disabled="disabled" @endif value="mobile"></td>
<td class="text-center">{{ __('Mobile') }}<br/><small>(<a href="{{ config('app.freescout_url') }}/module/mobile-notifications/" target="_blank">Android</a> / <a href="{{ config('app.freescout_url') }}/module/mobile-notifications/" target="_blank">iOS</a>)</small><br/><input type="checkbox" class="sel-all" @if (!$mobile_available) disabled="disabled" @endif value="mobile"></td>
@action('notifications_table.th')
</tr>
<tr>

View File

@@ -42,7 +42,7 @@ Date: Mon, 26 Dec 2022 08:07:14 -0800
From: =?UTF-8?B?0KLRgNCw0LvQsNC70LXQu9C+INCi0YDQsNC70LDQu9CwINCi0YDQsNC70LA=?=
=?UTF-8?B?0LvQtdC70L7QstC90LA=?= <notifications@github.com>
Reply-To: Webklex/php-imap <reply+aibuo5jooM2quaizuoyae0jeuk9queth@reply.github.com>
To: Webklex/php-imap <php-imap@noreply.github.com>
To: Webklex/php-imap<php-imap@noreply.github.com>
Cc: Subscribed <subscribed@noreply.github.com>
Message-ID: <Webklex/php-imap/issues/349/1365266070@github.com>
In-Reply-To: <Webklex/php-imap/issues/349@github.com>

View File

@@ -18,4 +18,26 @@ class MailTest extends TestCase
// self::assertSame('multipart/alternative;
// boundary="------------c141PQyZlgOaY0xUqE0ydJUA"', \MailHelper::getHeader($message, 'Content-Type'));
}
public function testFitImages(): void
{
// Wide image is scaled down preserving aspect ratio.
self::assertSame(
'<img src="a.png" alt="" width="580" height="326" style="max-width:100%;height:auto;">',
\MailHelper::fitImages('<img src="a.png" alt="" width="765" height="430">', 580)
);
// Narrow image keeps its dimensions.
self::assertSame(
'<p><img src="a.png" width="300" height="200" style="max-width:100%;height:auto;" /></p>',
\MailHelper::fitImages('<p><img src="a.png" width="300" height="200" /></p>', 580)
);
// Existing max-width and height are replaced, other styles are kept.
self::assertSame(
'<img src="a.png" style="width:765px; line-height:1; border:0;max-width:100%;height:auto;">',
\MailHelper::fitImages('<img src="a.png" style="width:765px; max-width:800px; line-height:1; height:430px; border:0">', 580)
);
// No images.
self::assertSame('<p>text</p>', \MailHelper::fitImages('<p>text</p>', 580));
self::assertSame('', \MailHelper::fitImages('', 580));
}
}

View File

@@ -43,7 +43,7 @@ class SsrfProtectionsTest extends TestCase
'example.org' => 'https://example.org',
];
foreach ($test_hosts as $host => $result) {
$this->assertEquals($result, \Helper::checkUrlIpAndHost('https://'.$host), $host);
$this->assertEquals($result, \Helper::checkUrlHost('https://'.$host), $host);
}
}
}

View File

@@ -73,6 +73,8 @@ class WebklexHeaderTest extends TestCase {
self::assertSame("php-imap", $to->mailbox);
self::assertSame("noreply.github.com", $to->host);
self::assertSame("php-imap@noreply.github.com", $to->mail);
// There can be no space before "<".
// https://github.com/freescout-help-desk/freescout/pull/5678
self::assertSame("Webklex/php-imap <php-imap@noreply.github.com>", $to->full);
self::assertInstanceOf(Carbon::class, $date);

View File

@@ -108,7 +108,7 @@ else
git fetch
new_commits=`git log "$branch..origin/$branch" --pretty=format:"%h %ad | %s%d [%an]" --graph --date=short | wc -l`;
new_commits=`git rev-list --count "$branch..origin/$branch"`;
if [ $new_commits = 0 ]; then
echo -e "\e[32mYou already have the latest version of the application, no upgrade needed.\e[0m";
exit;