Merge branch 'master' of github.com:freescout-helpdesk/freescout into dist
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>',
|
||||
]).' ';
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -12,6 +12,7 @@ class EncryptCookies extends Middleware
|
||||
* @var array
|
||||
*/
|
||||
protected $except = [
|
||||
'in_app'
|
||||
'in_app',
|
||||
'customers_view',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 (<![if !vml]>) in the DB.
|
||||
$body = preg_replace('/(?:<|<)!\[(?:if\s[^\]]*|endif)\](?:>|>)/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.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
)) {
|
||||
|
||||
@@ -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
162
public/css/style.css
vendored
@@ -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
118
public/js/main.js
vendored
@@ -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');
|
||||
|
||||
123
public/js/summernote/summernote.js
vendored
123
public/js/summernote/summernote.js
vendored
@@ -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'));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) @else<span>{{ $conversation->threads_count }}</span>@endif</a>--}}
|
||||
</td>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> # <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> # <strong>{{ $conversation->number }}</strong>
|
||||
</div>
|
||||
<div id="conv-viewers">
|
||||
@foreach ($viewers as $viewer)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user