mirror of
https://github.com/itflow-org/itflow
synced 2026-08-04 22:57:14 +00:00
Normalize line endings to LF; add .gitattributes and .editorconfig
This commit is contained in:
20
.editorconfig
Normal file
20
.editorconfig
Normal file
@@ -0,0 +1,20 @@
|
||||
# Editor defaults for ITFlow - see CONTRIBUTING.md ("Style")
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Vendored - do not reformat
|
||||
[libs/**]
|
||||
indent_style = unset
|
||||
indent_size = unset
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
44
.gitattributes
vendored
Normal file
44
.gitattributes
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# ITFlow line-ending policy
|
||||
#
|
||||
# Everything ITFlow ships is LF in the repository and LF in the working tree.
|
||||
# Contributors on Windows get LF too - this is deliberate. ITFlow is deployed
|
||||
# to Linux/Apache and edited over sftp/ssh as often as it is cloned, so a
|
||||
# checkout must be byte-identical everywhere.
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicit for the file types we author, so nothing depends on git's guess.
|
||||
*.php text eol=lf
|
||||
*.js text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.sql text eol=lf
|
||||
*.md text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.xsd text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.ini text eol=lf
|
||||
.htaccess text eol=lf
|
||||
|
||||
# Binary assets: never touched, never diffed as text.
|
||||
*.png binary
|
||||
*.gif binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.icc binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.crt binary
|
||||
*.ser binary
|
||||
*.z binary
|
||||
|
||||
# Vendored third-party code is preserved byte-for-byte as shipped upstream.
|
||||
# Per CONTRIBUTING.md libs/ is never edited in place - it is replaced wholesale -
|
||||
# so normalizing it here would create spurious diffs on the next library update.
|
||||
libs/** -text
|
||||
@@ -1,103 +1,103 @@
|
||||
<?php
|
||||
|
||||
require_once "../config.php";
|
||||
require_once "../functions.php";
|
||||
require_once "../includes/check_login.php";
|
||||
|
||||
$settings_mail_path = '/admin/settings_mail.php?tab=oauth';
|
||||
|
||||
if (!isset($session_is_admin) || !$session_is_admin) {
|
||||
flashAlert("Admin access required.", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
$state = escapeSql($_GET['state'] ?? '');
|
||||
$code = $_GET['code'] ?? '';
|
||||
$error = escapeSql($_GET['error'] ?? '');
|
||||
$error_description = escapeSql($_GET['error_description'] ?? '');
|
||||
|
||||
$session_state = $_SESSION['mail_oauth_state'] ?? '';
|
||||
$session_state_expires = intval($_SESSION['mail_oauth_state_expires_at'] ?? 0);
|
||||
|
||||
unset($_SESSION['mail_oauth_state'], $_SESSION['mail_oauth_state_expires_at']);
|
||||
|
||||
if (!empty($error)) {
|
||||
$msg = "Microsoft OAuth authorization failed: $error";
|
||||
if (!empty($error_description)) {
|
||||
$msg .= " ($error_description)";
|
||||
}
|
||||
|
||||
flashAlert($msg, 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
if (empty($state) || empty($code) || empty($session_state) || !hash_equals($session_state, $state) || time() > $session_state_expires) {
|
||||
flashAlert("Microsoft OAuth callback validation failed. Please try connecting again.", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_tenant_id)) {
|
||||
flashAlert("Microsoft OAuth settings are incomplete. Please fill Client ID, Client Secret, and Tenant ID.", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
if (defined('BASE_URL') && !empty(BASE_URL)) {
|
||||
$base_url = rtrim((string) BASE_URL, '/');
|
||||
} else {
|
||||
$base_url = 'https://' . rtrim((string) $config_base_url, '/');
|
||||
}
|
||||
|
||||
$redirect_uri = $base_url . '/admin/oauth_microsoft_mail_callback.php';
|
||||
$token_url = 'https://login.microsoftonline.com/' . rawurlencode($config_mail_oauth_tenant_id) . '/oauth2/v2.0/token';
|
||||
$scope = 'offline_access openid profile https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send';
|
||||
|
||||
$ch = curl_init($token_url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'client_id' => $config_mail_oauth_client_id,
|
||||
'client_secret' => $config_mail_oauth_client_secret,
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'scope' => $scope,
|
||||
], '', '&'));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
||||
|
||||
$raw_body = curl_exec($ch);
|
||||
$curl_err = curl_error($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw_body === false || $http_code < 200 || $http_code >= 300) {
|
||||
$reason = !empty($curl_err) ? $curl_err : "HTTP $http_code";
|
||||
flashAlert("Microsoft OAuth token exchange failed: $reason", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
$json = json_decode($raw_body, true);
|
||||
if (!is_array($json) || empty($json['refresh_token']) || empty($json['access_token'])) {
|
||||
flashAlert("Microsoft OAuth token exchange failed: refresh token or access token missing.", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
$refresh_token = (string) $json['refresh_token'];
|
||||
$access_token = (string) $json['access_token'];
|
||||
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
|
||||
|
||||
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token);
|
||||
$access_token_esc = mysqli_real_escape_string($mysqli, $access_token);
|
||||
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
|
||||
|
||||
mysqli_query($mysqli, "UPDATE settings SET
|
||||
config_imap_provider = 'microsoft_oauth',
|
||||
config_smtp_provider = 'microsoft_oauth',
|
||||
config_mail_oauth_refresh_token = '$refresh_token_esc',
|
||||
config_mail_oauth_access_token = '$access_token_esc',
|
||||
config_mail_oauth_access_token_expires_at = '$expires_at_esc'
|
||||
WHERE company_id = 1
|
||||
");
|
||||
|
||||
logAudit("Settings", "Edit", "$session_name completed Microsoft OAuth connect flow for mail settings");
|
||||
flashAlert("Microsoft OAuth connected successfully. Token expires at $expires_at.");
|
||||
redirect($settings_mail_path);
|
||||
<?php
|
||||
|
||||
require_once "../config.php";
|
||||
require_once "../functions.php";
|
||||
require_once "../includes/check_login.php";
|
||||
|
||||
$settings_mail_path = '/admin/settings_mail.php?tab=oauth';
|
||||
|
||||
if (!isset($session_is_admin) || !$session_is_admin) {
|
||||
flashAlert("Admin access required.", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
$state = escapeSql($_GET['state'] ?? '');
|
||||
$code = $_GET['code'] ?? '';
|
||||
$error = escapeSql($_GET['error'] ?? '');
|
||||
$error_description = escapeSql($_GET['error_description'] ?? '');
|
||||
|
||||
$session_state = $_SESSION['mail_oauth_state'] ?? '';
|
||||
$session_state_expires = intval($_SESSION['mail_oauth_state_expires_at'] ?? 0);
|
||||
|
||||
unset($_SESSION['mail_oauth_state'], $_SESSION['mail_oauth_state_expires_at']);
|
||||
|
||||
if (!empty($error)) {
|
||||
$msg = "Microsoft OAuth authorization failed: $error";
|
||||
if (!empty($error_description)) {
|
||||
$msg .= " ($error_description)";
|
||||
}
|
||||
|
||||
flashAlert($msg, 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
if (empty($state) || empty($code) || empty($session_state) || !hash_equals($session_state, $state) || time() > $session_state_expires) {
|
||||
flashAlert("Microsoft OAuth callback validation failed. Please try connecting again.", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_tenant_id)) {
|
||||
flashAlert("Microsoft OAuth settings are incomplete. Please fill Client ID, Client Secret, and Tenant ID.", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
if (defined('BASE_URL') && !empty(BASE_URL)) {
|
||||
$base_url = rtrim((string) BASE_URL, '/');
|
||||
} else {
|
||||
$base_url = 'https://' . rtrim((string) $config_base_url, '/');
|
||||
}
|
||||
|
||||
$redirect_uri = $base_url . '/admin/oauth_microsoft_mail_callback.php';
|
||||
$token_url = 'https://login.microsoftonline.com/' . rawurlencode($config_mail_oauth_tenant_id) . '/oauth2/v2.0/token';
|
||||
$scope = 'offline_access openid profile https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send';
|
||||
|
||||
$ch = curl_init($token_url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'client_id' => $config_mail_oauth_client_id,
|
||||
'client_secret' => $config_mail_oauth_client_secret,
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'scope' => $scope,
|
||||
], '', '&'));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
||||
|
||||
$raw_body = curl_exec($ch);
|
||||
$curl_err = curl_error($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw_body === false || $http_code < 200 || $http_code >= 300) {
|
||||
$reason = !empty($curl_err) ? $curl_err : "HTTP $http_code";
|
||||
flashAlert("Microsoft OAuth token exchange failed: $reason", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
$json = json_decode($raw_body, true);
|
||||
if (!is_array($json) || empty($json['refresh_token']) || empty($json['access_token'])) {
|
||||
flashAlert("Microsoft OAuth token exchange failed: refresh token or access token missing.", 'error');
|
||||
redirect($settings_mail_path);
|
||||
}
|
||||
|
||||
$refresh_token = (string) $json['refresh_token'];
|
||||
$access_token = (string) $json['access_token'];
|
||||
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
|
||||
|
||||
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token);
|
||||
$access_token_esc = mysqli_real_escape_string($mysqli, $access_token);
|
||||
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
|
||||
|
||||
mysqli_query($mysqli, "UPDATE settings SET
|
||||
config_imap_provider = 'microsoft_oauth',
|
||||
config_smtp_provider = 'microsoft_oauth',
|
||||
config_mail_oauth_refresh_token = '$refresh_token_esc',
|
||||
config_mail_oauth_access_token = '$access_token_esc',
|
||||
config_mail_oauth_access_token_expires_at = '$expires_at_esc'
|
||||
WHERE company_id = 1
|
||||
");
|
||||
|
||||
logAudit("Settings", "Edit", "$session_name completed Microsoft OAuth connect flow for mail settings");
|
||||
flashAlert("Microsoft OAuth connected successfully. Token expires at $expires_at.");
|
||||
redirect($settings_mail_path);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,476 +1,476 @@
|
||||
<?php
|
||||
// Set working directory to the directory this cron script lives at.
|
||||
chdir(dirname(__FILE__));
|
||||
|
||||
// Ensure we're running from command line
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
die("This script must be run from the command line.\n");
|
||||
}
|
||||
|
||||
// Prevent overlapping runs of this script
|
||||
$cron_lock_script = __FILE__;
|
||||
require_once "../includes/cron_lock.php";
|
||||
|
||||
require_once "../config.php";
|
||||
require_once "../includes/inc_set_timezone.php";
|
||||
require_once "../functions.php";
|
||||
require_once "../libs/vendor/autoload.php";
|
||||
|
||||
// PHP Mailer Libs
|
||||
require_once "../libs/PHPMailer/src/Exception.php";
|
||||
require_once "../libs/PHPMailer/src/PHPMailer.php";
|
||||
require_once "../libs/PHPMailer/src/SMTP.php";
|
||||
require_once "../libs/PHPMailer/src/OAuthTokenProvider.php";
|
||||
require_once "../libs/PHPMailer/src/OAuth.php";
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
use PHPMailer\PHPMailer\OAuthTokenProvider;
|
||||
|
||||
if (!defined('GOOGLE_OAUTH_TOKEN_URL')) {
|
||||
define('GOOGLE_OAUTH_TOKEN_URL', 'https://oauth2.googleapis.com/token');
|
||||
}
|
||||
|
||||
if (!defined('MICROSOFT_OAUTH_BASE_URL')) {
|
||||
define('MICROSOFT_OAUTH_BASE_URL', 'https://login.microsoftonline.com/');
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* XOAUTH2 Token Provider for PHPMailer (simple “static” provider)
|
||||
* ======================================================================= */
|
||||
class StaticTokenProvider implements OAuthTokenProvider {
|
||||
private string $email;
|
||||
private string $accessToken;
|
||||
public function __construct(string $email, string $accessToken) {
|
||||
$this->email = $email;
|
||||
$this->accessToken = $accessToken;
|
||||
}
|
||||
public function getOauth64(): string {
|
||||
$auth = "user={$this->email}\x01auth=Bearer {$this->accessToken}\x01\x01";
|
||||
return base64_encode($auth);
|
||||
}
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* Load settings
|
||||
* ======================================================================= */
|
||||
$sql_settings = mysqli_query($mysqli, "SELECT * FROM settings WHERE company_id = 1");
|
||||
$row = mysqli_fetch_assoc($sql_settings);
|
||||
|
||||
$config_enable_cron = intval($row['config_enable_cron']);
|
||||
|
||||
// SMTP baseline
|
||||
$config_smtp_host = $row['config_smtp_host'];
|
||||
$config_smtp_username = $row['config_smtp_username'];
|
||||
$config_smtp_password = $row['config_smtp_password'];
|
||||
$config_smtp_port = intval($row['config_smtp_port']);
|
||||
$config_smtp_encryption = $row['config_smtp_encryption'];
|
||||
|
||||
// SMTP provider + shared OAuth fields
|
||||
$config_smtp_provider = $row['config_smtp_provider']; // 'standard_smtp' | 'google_oauth' | 'microsoft_oauth'
|
||||
$config_mail_oauth_client_id = $row['config_mail_oauth_client_id'] ?? '';
|
||||
$config_mail_oauth_client_secret = $row['config_mail_oauth_client_secret'] ?? '';
|
||||
$config_mail_oauth_tenant_id = $row['config_mail_oauth_tenant_id'] ?? '';
|
||||
$config_mail_oauth_refresh_token = $row['config_mail_oauth_refresh_token'] ?? '';
|
||||
$config_mail_oauth_access_token = $row['config_mail_oauth_access_token'] ?? '';
|
||||
$config_mail_oauth_access_token_expires_at = $row['config_mail_oauth_access_token_expires_at'] ?? '';
|
||||
|
||||
if ($config_enable_cron == 0) {
|
||||
logApp("Cron-Mail-Queue", "error", "Cron Mail Queue unable to run - cron not enabled in admin settings.");
|
||||
exit("Cron: is not enabled -- Quitting..");
|
||||
}
|
||||
|
||||
if (empty($config_smtp_provider)) {
|
||||
logApp("Cron-Mail-Queue", "info", "SMTP sending skipped: provider not configured.");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* Mail OAuth helpers + sender function
|
||||
* ======================================================================= */
|
||||
function tokenIsExpired(?string $expires_at): bool {
|
||||
if (empty($expires_at)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ts = strtotime($expires_at);
|
||||
|
||||
if ($ts === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ($ts - 60) <= time();
|
||||
}
|
||||
|
||||
function httpFormPost(string $url, array $fields): array {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields, '', '&'));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'ok' => ($raw !== false && $code >= 200 && $code < 300),
|
||||
'body' => $raw,
|
||||
'code' => $code,
|
||||
'err' => $err,
|
||||
];
|
||||
}
|
||||
|
||||
function persistMailOauthTokens(string $access_token, string $expires_at, ?string $refresh_token = null): void {
|
||||
global $mysqli;
|
||||
|
||||
$access_token_esc = mysqli_real_escape_string($mysqli, $access_token);
|
||||
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
|
||||
|
||||
$refresh_sql = '';
|
||||
if (!empty($refresh_token)) {
|
||||
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token);
|
||||
$refresh_sql = ", config_mail_oauth_refresh_token = '{$refresh_token_esc}'";
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE settings SET config_mail_oauth_access_token = '{$access_token_esc}', config_mail_oauth_access_token_expires_at = '{$expires_at_esc}'{$refresh_sql} WHERE company_id = 1");
|
||||
}
|
||||
|
||||
function refreshMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token): ?array {
|
||||
$result = null;
|
||||
$response = null;
|
||||
|
||||
if (!empty($oauth_client_id) && !empty($oauth_client_secret) && !empty($oauth_refresh_token)) {
|
||||
if ($provider === 'google_oauth') {
|
||||
$response = httpFormPost(GOOGLE_OAUTH_TOKEN_URL, [
|
||||
'client_id' => $oauth_client_id,
|
||||
'client_secret' => $oauth_client_secret,
|
||||
'refresh_token' => $oauth_refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
]);
|
||||
} elseif ($provider === 'microsoft_oauth' && !empty($oauth_tenant_id)) {
|
||||
$token_url = MICROSOFT_OAUTH_BASE_URL . rawurlencode($oauth_tenant_id) . "/oauth2/v2.0/token";
|
||||
$response = httpFormPost($token_url, [
|
||||
'client_id' => $oauth_client_id,
|
||||
'client_secret' => $oauth_client_secret,
|
||||
'refresh_token' => $oauth_refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($response) && !empty($response['ok'])) {
|
||||
$json = json_decode($response['body'], true);
|
||||
|
||||
if (is_array($json) && !empty($json['access_token'])) {
|
||||
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
|
||||
$result = [
|
||||
'access_token' => $json['access_token'],
|
||||
'expires_at' => $expires_at,
|
||||
'refresh_token' => $json['refresh_token'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function resolveMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token, string $oauth_access_token, string $oauth_access_token_expires_at): ?string {
|
||||
if (!empty($oauth_access_token) && !tokenIsExpired($oauth_access_token_expires_at)) {
|
||||
return $oauth_access_token;
|
||||
}
|
||||
|
||||
$tokens = refreshMailOauthAccessToken($provider, $oauth_client_id, $oauth_client_secret, $oauth_tenant_id, $oauth_refresh_token);
|
||||
|
||||
if (!is_array($tokens) || empty($tokens['access_token']) || empty($tokens['expires_at'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
persistMailOauthTokens($tokens['access_token'], $tokens['expires_at'], $tokens['refresh_token'] ?? null);
|
||||
|
||||
return $tokens['access_token'];
|
||||
}
|
||||
|
||||
function sendQueueEmail(
|
||||
string $provider,
|
||||
string $host,
|
||||
int $port,
|
||||
string $encryption,
|
||||
string $username,
|
||||
string $password,
|
||||
string $from_email,
|
||||
string $from_name,
|
||||
string $to_email,
|
||||
string $to_name,
|
||||
string $subject,
|
||||
string $html_body,
|
||||
string $ics_str,
|
||||
string $oauth_client_id,
|
||||
string $oauth_client_secret,
|
||||
string $oauth_tenant_id,
|
||||
string $oauth_refresh_token,
|
||||
string $oauth_access_token,
|
||||
string $oauth_access_token_expires_at
|
||||
) {
|
||||
// Sensible defaults for OAuth providers if fields were left blank
|
||||
if ($provider === 'google_oauth') {
|
||||
if (!$host) $host = 'smtp.gmail.com';
|
||||
if (!$port) $port = 587;
|
||||
if (!$encryption) $encryption = 'tls';
|
||||
if (!$username) $username = $from_email;
|
||||
} elseif ($provider === 'microsoft_oauth') {
|
||||
if (!$host) $host = 'smtp.office365.com';
|
||||
if (!$port) $port = 587;
|
||||
if (!$encryption) $encryption = 'tls';
|
||||
if (!$username) $username = $from_email;
|
||||
}
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
$mail->CharSet = "UTF-8";
|
||||
$mail->SMTPDebug = 0;
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $host;
|
||||
$mail->Port = $port;
|
||||
// Bound the SMTP conversation. Without this an unresponsive mail server can
|
||||
// hold the cron lock open indefinitely and stall the whole queue.
|
||||
$mail->Timeout = 30;
|
||||
|
||||
$enc = strtolower($encryption);
|
||||
if ($enc === '' || $enc === 'none') {
|
||||
$mail->SMTPAutoTLS = false;
|
||||
$mail->SMTPSecure = false;
|
||||
$mail->SMTPOptions = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]];
|
||||
} else {
|
||||
$mail->SMTPSecure = $enc; // 'tls' | 'ssl'
|
||||
}
|
||||
|
||||
if ($provider === 'google_oauth' || $provider === 'microsoft_oauth') {
|
||||
// XOAUTH2
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->AuthType = 'XOAUTH2';
|
||||
$mail->Username = $username;
|
||||
|
||||
$access_token = resolveMailOauthAccessToken(
|
||||
$provider,
|
||||
trim($oauth_client_id),
|
||||
trim($oauth_client_secret),
|
||||
trim($oauth_tenant_id),
|
||||
trim($oauth_refresh_token),
|
||||
trim($oauth_access_token),
|
||||
trim($oauth_access_token_expires_at)
|
||||
);
|
||||
|
||||
if (empty($access_token)) {
|
||||
throw new Exception("Missing OAuth access token for XOAUTH2 SMTP.");
|
||||
}
|
||||
|
||||
$mail->setOAuth(new StaticTokenProvider($username, $access_token));
|
||||
} else {
|
||||
// Standard SMTP (with or without auth)
|
||||
$mail->SMTPAuth = !empty($username);
|
||||
$mail->Username = $username ?: '';
|
||||
$mail->Password = $password ?: '';
|
||||
}
|
||||
|
||||
// Recipients & content
|
||||
$mail->setFrom($from_email, $from_name);
|
||||
$mail->addAddress($to_email, $to_name);
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $html_body;
|
||||
|
||||
if (!empty($ics_str)) {
|
||||
$mail->addStringAttachment($ics_str, 'Scheduled_ticket.ics', 'base64', 'text/calendar');
|
||||
}
|
||||
|
||||
$mail->send();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* RECOVER: status = 1 (Sending) left behind by a run that died
|
||||
*
|
||||
* Nothing else in the codebase ever selects status 1, so without this a row
|
||||
* claimed by a run that was killed mid-send stays 'Sending' forever and is never
|
||||
* delivered. The cron lock above guarantees no other run of this script is in
|
||||
* progress, so any row still sitting at status 1 is by definition orphaned and
|
||||
* safe to reclaim. It is moved to failed rather than queued so it inherits the
|
||||
* retry pass's 30 minute backoff and attempt cap instead of retrying instantly.
|
||||
*
|
||||
* This can re-send a message that did go out but died before being marked sent.
|
||||
* That trade is deliberate: a duplicate is recoverable, an invoice that silently
|
||||
* never arrives is not.
|
||||
* ======================================================================= */
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = email_attempts + 1 WHERE email_status = 1");
|
||||
$orphaned_emails = mysqli_affected_rows($mysqli);
|
||||
if ($orphaned_emails > 0) {
|
||||
logApp("Cron-Mail-Queue", "warning", "Recovered $orphaned_emails email(s) left in a sending state by a previous run - queued for retry.");
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* SEND: status = 0 (Queued)
|
||||
* ======================================================================= */
|
||||
$sql_queue = mysqli_query($mysqli, "SELECT * FROM email_queue WHERE email_status = 0 AND email_queued_at <= NOW()");
|
||||
|
||||
if (mysqli_num_rows($sql_queue) > 0) {
|
||||
while ($rowq = mysqli_fetch_assoc($sql_queue)) {
|
||||
$email_id = (int)$rowq['email_id'];
|
||||
$email_from = $rowq['email_from'];
|
||||
$email_from_name = $rowq['email_from_name'];
|
||||
$email_recipient = $rowq['email_recipient'];
|
||||
$email_recipient_name = $rowq['email_recipient_name'];
|
||||
$email_subject = $rowq['email_subject'];
|
||||
$email_content = $rowq['email_content'];
|
||||
$email_ics_str = $rowq['email_cal_str'];
|
||||
|
||||
// Check sender
|
||||
if (!filter_var($email_from, FILTER_VALIDATE_EMAIL)) {
|
||||
$email_from_logging = escapeSql($rowq['email_from']);
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to send email #$email_id due to invalid sender address: $email_from_logging - check configuration in settings.");
|
||||
appNotify("Mail", "Failed to send email #$email_id due to invalid sender address");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Claim the row - the conditional UPDATE is the lock. If another run already took
|
||||
// this email, skip it rather than sending the client a second copy.
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 0");
|
||||
if (mysqli_affected_rows($mysqli) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Basic recipient syntax check
|
||||
if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
|
||||
$email_to_logging = escapeSql($email_recipient);
|
||||
$email_subject_logging = escapeSql($rowq['email_subject']);
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient address. Email subject was: $email_subject_logging");
|
||||
appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient address: Email subject was: $email_subject_logging");
|
||||
continue;
|
||||
}
|
||||
|
||||
// More intelligent recipient MX check (if not disabled with --no-mx-validation)
|
||||
$domain = escapeSql(substr($email_recipient, strpos($email_recipient, '@') + 1));
|
||||
if (!in_array('--no-mx-validation', $argv) && !checkdnsrr($domain, 'MX')) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
|
||||
$email_to_logging = escapeSql($email_recipient);
|
||||
$email_subject_logging = escapeSql($rowq['email_subject']);
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient domain (no MX). Email subject was: $email_subject_logging");
|
||||
appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient domain (no MX): Email subject was: $email_subject_logging");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
sendQueueEmail(
|
||||
($config_smtp_provider ?: 'standard_smtp'),
|
||||
$config_smtp_host,
|
||||
(int)$config_smtp_port,
|
||||
(string)$config_smtp_encryption,
|
||||
(string)$config_smtp_username,
|
||||
(string)$config_smtp_password,
|
||||
(string)$email_from,
|
||||
(string)$email_from_name,
|
||||
(string)$email_recipient,
|
||||
(string)$email_recipient_name,
|
||||
(string)$email_subject,
|
||||
(string)$email_content,
|
||||
(string)$email_ics_str,
|
||||
(string)$config_mail_oauth_client_id,
|
||||
(string)$config_mail_oauth_client_secret,
|
||||
(string)$config_mail_oauth_tenant_id,
|
||||
(string)$config_mail_oauth_refresh_token,
|
||||
(string)$config_mail_oauth_access_token,
|
||||
(string)$config_mail_oauth_access_token_expires_at
|
||||
);
|
||||
|
||||
// Scrub the body on delivery - it can carry share decryption keys and temporary passwords
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = 1, email_content = '', email_cal_str = '' WHERE email_id = $email_id");
|
||||
|
||||
} catch (Exception $e) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = 1 WHERE email_id = $email_id");
|
||||
|
||||
$email_recipient_logging = escapeSql($rowq['email_recipient']);
|
||||
$email_subject_logging = escapeSql($rowq['email_subject']);
|
||||
$err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "...";
|
||||
|
||||
appNotify("Cron-Mail-Queue", "Failed to send email #$email_id to $email_recipient_logging");
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_recipient_logging regarding $email_subject_logging. $err");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* RETRIES: status = 2 (Failed), attempts < 4, wait 30 min
|
||||
* NOTE: Backoff is `email_failed_at <= NOW() - INTERVAL 30 MINUTE`
|
||||
* =======================================================================
|
||||
*/
|
||||
$sql_failed_queue = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT * FROM email_queue
|
||||
WHERE email_status = 2
|
||||
AND email_attempts < 4
|
||||
AND email_failed_at <= NOW() - INTERVAL 30 MINUTE"
|
||||
);
|
||||
|
||||
if (mysqli_num_rows($sql_failed_queue) > 0) {
|
||||
while ($rowf = mysqli_fetch_assoc($sql_failed_queue)) {
|
||||
$email_id = (int)$rowf['email_id'];
|
||||
$email_from = $rowf['email_from'];
|
||||
$email_from_name = $rowf['email_from_name'];
|
||||
$email_recipient = $rowf['email_recipient'];
|
||||
$email_recipient_name = $rowf['email_recipient_name'];
|
||||
$email_subject = $rowf['email_subject'];
|
||||
$email_content = $rowf['email_content'];
|
||||
$email_ics_str = $rowf['email_cal_str'];
|
||||
$email_attempts = (int)$rowf['email_attempts'] + 1;
|
||||
|
||||
// Claim the row - same lock as the send path, from the failed state this time.
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 2");
|
||||
if (mysqli_affected_rows($mysqli) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = $email_attempts WHERE email_id = $email_id");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
sendQueueEmail(
|
||||
($config_smtp_provider ?: 'standard_smtp'),
|
||||
$config_smtp_host,
|
||||
(int)$config_smtp_port,
|
||||
(string)$config_smtp_encryption,
|
||||
(string)$config_smtp_username,
|
||||
(string)$config_smtp_password,
|
||||
(string)$email_from,
|
||||
(string)$email_from_name,
|
||||
(string)$email_recipient,
|
||||
(string)$email_recipient_name,
|
||||
(string)$email_subject,
|
||||
(string)$email_content,
|
||||
(string)$email_ics_str,
|
||||
(string)$config_mail_oauth_client_id,
|
||||
(string)$config_mail_oauth_client_secret,
|
||||
(string)$config_mail_oauth_tenant_id,
|
||||
(string)$config_mail_oauth_refresh_token,
|
||||
(string)$config_mail_oauth_access_token,
|
||||
(string)$config_mail_oauth_access_token_expires_at
|
||||
);
|
||||
|
||||
// Scrub the body on delivery - it can carry share decryption keys and temporary passwords
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = $email_attempts, email_content = '', email_cal_str = '' WHERE email_id = $email_id");
|
||||
|
||||
} catch (Exception $e) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = $email_attempts WHERE email_id = $email_id");
|
||||
|
||||
$email_recipient_logging = escapeSql($rowf['email_recipient']);
|
||||
$email_subject_logging = escapeSql($rowf['email_subject']);
|
||||
$err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "...";
|
||||
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to re-send email #$email_id to $email_recipient_logging regarding $email_subject_logging. $err");
|
||||
}
|
||||
}
|
||||
}
|
||||
<?php
|
||||
// Set working directory to the directory this cron script lives at.
|
||||
chdir(dirname(__FILE__));
|
||||
|
||||
// Ensure we're running from command line
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
die("This script must be run from the command line.\n");
|
||||
}
|
||||
|
||||
// Prevent overlapping runs of this script
|
||||
$cron_lock_script = __FILE__;
|
||||
require_once "../includes/cron_lock.php";
|
||||
|
||||
require_once "../config.php";
|
||||
require_once "../includes/inc_set_timezone.php";
|
||||
require_once "../functions.php";
|
||||
require_once "../libs/vendor/autoload.php";
|
||||
|
||||
// PHP Mailer Libs
|
||||
require_once "../libs/PHPMailer/src/Exception.php";
|
||||
require_once "../libs/PHPMailer/src/PHPMailer.php";
|
||||
require_once "../libs/PHPMailer/src/SMTP.php";
|
||||
require_once "../libs/PHPMailer/src/OAuthTokenProvider.php";
|
||||
require_once "../libs/PHPMailer/src/OAuth.php";
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
use PHPMailer\PHPMailer\OAuthTokenProvider;
|
||||
|
||||
if (!defined('GOOGLE_OAUTH_TOKEN_URL')) {
|
||||
define('GOOGLE_OAUTH_TOKEN_URL', 'https://oauth2.googleapis.com/token');
|
||||
}
|
||||
|
||||
if (!defined('MICROSOFT_OAUTH_BASE_URL')) {
|
||||
define('MICROSOFT_OAUTH_BASE_URL', 'https://login.microsoftonline.com/');
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* XOAUTH2 Token Provider for PHPMailer (simple “static” provider)
|
||||
* ======================================================================= */
|
||||
class StaticTokenProvider implements OAuthTokenProvider {
|
||||
private string $email;
|
||||
private string $accessToken;
|
||||
public function __construct(string $email, string $accessToken) {
|
||||
$this->email = $email;
|
||||
$this->accessToken = $accessToken;
|
||||
}
|
||||
public function getOauth64(): string {
|
||||
$auth = "user={$this->email}\x01auth=Bearer {$this->accessToken}\x01\x01";
|
||||
return base64_encode($auth);
|
||||
}
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* Load settings
|
||||
* ======================================================================= */
|
||||
$sql_settings = mysqli_query($mysqli, "SELECT * FROM settings WHERE company_id = 1");
|
||||
$row = mysqli_fetch_assoc($sql_settings);
|
||||
|
||||
$config_enable_cron = intval($row['config_enable_cron']);
|
||||
|
||||
// SMTP baseline
|
||||
$config_smtp_host = $row['config_smtp_host'];
|
||||
$config_smtp_username = $row['config_smtp_username'];
|
||||
$config_smtp_password = $row['config_smtp_password'];
|
||||
$config_smtp_port = intval($row['config_smtp_port']);
|
||||
$config_smtp_encryption = $row['config_smtp_encryption'];
|
||||
|
||||
// SMTP provider + shared OAuth fields
|
||||
$config_smtp_provider = $row['config_smtp_provider']; // 'standard_smtp' | 'google_oauth' | 'microsoft_oauth'
|
||||
$config_mail_oauth_client_id = $row['config_mail_oauth_client_id'] ?? '';
|
||||
$config_mail_oauth_client_secret = $row['config_mail_oauth_client_secret'] ?? '';
|
||||
$config_mail_oauth_tenant_id = $row['config_mail_oauth_tenant_id'] ?? '';
|
||||
$config_mail_oauth_refresh_token = $row['config_mail_oauth_refresh_token'] ?? '';
|
||||
$config_mail_oauth_access_token = $row['config_mail_oauth_access_token'] ?? '';
|
||||
$config_mail_oauth_access_token_expires_at = $row['config_mail_oauth_access_token_expires_at'] ?? '';
|
||||
|
||||
if ($config_enable_cron == 0) {
|
||||
logApp("Cron-Mail-Queue", "error", "Cron Mail Queue unable to run - cron not enabled in admin settings.");
|
||||
exit("Cron: is not enabled -- Quitting..");
|
||||
}
|
||||
|
||||
if (empty($config_smtp_provider)) {
|
||||
logApp("Cron-Mail-Queue", "info", "SMTP sending skipped: provider not configured.");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* Mail OAuth helpers + sender function
|
||||
* ======================================================================= */
|
||||
function tokenIsExpired(?string $expires_at): bool {
|
||||
if (empty($expires_at)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ts = strtotime($expires_at);
|
||||
|
||||
if ($ts === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ($ts - 60) <= time();
|
||||
}
|
||||
|
||||
function httpFormPost(string $url, array $fields): array {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields, '', '&'));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'ok' => ($raw !== false && $code >= 200 && $code < 300),
|
||||
'body' => $raw,
|
||||
'code' => $code,
|
||||
'err' => $err,
|
||||
];
|
||||
}
|
||||
|
||||
function persistMailOauthTokens(string $access_token, string $expires_at, ?string $refresh_token = null): void {
|
||||
global $mysqli;
|
||||
|
||||
$access_token_esc = mysqli_real_escape_string($mysqli, $access_token);
|
||||
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
|
||||
|
||||
$refresh_sql = '';
|
||||
if (!empty($refresh_token)) {
|
||||
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token);
|
||||
$refresh_sql = ", config_mail_oauth_refresh_token = '{$refresh_token_esc}'";
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE settings SET config_mail_oauth_access_token = '{$access_token_esc}', config_mail_oauth_access_token_expires_at = '{$expires_at_esc}'{$refresh_sql} WHERE company_id = 1");
|
||||
}
|
||||
|
||||
function refreshMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token): ?array {
|
||||
$result = null;
|
||||
$response = null;
|
||||
|
||||
if (!empty($oauth_client_id) && !empty($oauth_client_secret) && !empty($oauth_refresh_token)) {
|
||||
if ($provider === 'google_oauth') {
|
||||
$response = httpFormPost(GOOGLE_OAUTH_TOKEN_URL, [
|
||||
'client_id' => $oauth_client_id,
|
||||
'client_secret' => $oauth_client_secret,
|
||||
'refresh_token' => $oauth_refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
]);
|
||||
} elseif ($provider === 'microsoft_oauth' && !empty($oauth_tenant_id)) {
|
||||
$token_url = MICROSOFT_OAUTH_BASE_URL . rawurlencode($oauth_tenant_id) . "/oauth2/v2.0/token";
|
||||
$response = httpFormPost($token_url, [
|
||||
'client_id' => $oauth_client_id,
|
||||
'client_secret' => $oauth_client_secret,
|
||||
'refresh_token' => $oauth_refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($response) && !empty($response['ok'])) {
|
||||
$json = json_decode($response['body'], true);
|
||||
|
||||
if (is_array($json) && !empty($json['access_token'])) {
|
||||
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
|
||||
$result = [
|
||||
'access_token' => $json['access_token'],
|
||||
'expires_at' => $expires_at,
|
||||
'refresh_token' => $json['refresh_token'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function resolveMailOauthAccessToken(string $provider, string $oauth_client_id, string $oauth_client_secret, string $oauth_tenant_id, string $oauth_refresh_token, string $oauth_access_token, string $oauth_access_token_expires_at): ?string {
|
||||
if (!empty($oauth_access_token) && !tokenIsExpired($oauth_access_token_expires_at)) {
|
||||
return $oauth_access_token;
|
||||
}
|
||||
|
||||
$tokens = refreshMailOauthAccessToken($provider, $oauth_client_id, $oauth_client_secret, $oauth_tenant_id, $oauth_refresh_token);
|
||||
|
||||
if (!is_array($tokens) || empty($tokens['access_token']) || empty($tokens['expires_at'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
persistMailOauthTokens($tokens['access_token'], $tokens['expires_at'], $tokens['refresh_token'] ?? null);
|
||||
|
||||
return $tokens['access_token'];
|
||||
}
|
||||
|
||||
function sendQueueEmail(
|
||||
string $provider,
|
||||
string $host,
|
||||
int $port,
|
||||
string $encryption,
|
||||
string $username,
|
||||
string $password,
|
||||
string $from_email,
|
||||
string $from_name,
|
||||
string $to_email,
|
||||
string $to_name,
|
||||
string $subject,
|
||||
string $html_body,
|
||||
string $ics_str,
|
||||
string $oauth_client_id,
|
||||
string $oauth_client_secret,
|
||||
string $oauth_tenant_id,
|
||||
string $oauth_refresh_token,
|
||||
string $oauth_access_token,
|
||||
string $oauth_access_token_expires_at
|
||||
) {
|
||||
// Sensible defaults for OAuth providers if fields were left blank
|
||||
if ($provider === 'google_oauth') {
|
||||
if (!$host) $host = 'smtp.gmail.com';
|
||||
if (!$port) $port = 587;
|
||||
if (!$encryption) $encryption = 'tls';
|
||||
if (!$username) $username = $from_email;
|
||||
} elseif ($provider === 'microsoft_oauth') {
|
||||
if (!$host) $host = 'smtp.office365.com';
|
||||
if (!$port) $port = 587;
|
||||
if (!$encryption) $encryption = 'tls';
|
||||
if (!$username) $username = $from_email;
|
||||
}
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
$mail->CharSet = "UTF-8";
|
||||
$mail->SMTPDebug = 0;
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $host;
|
||||
$mail->Port = $port;
|
||||
// Bound the SMTP conversation. Without this an unresponsive mail server can
|
||||
// hold the cron lock open indefinitely and stall the whole queue.
|
||||
$mail->Timeout = 30;
|
||||
|
||||
$enc = strtolower($encryption);
|
||||
if ($enc === '' || $enc === 'none') {
|
||||
$mail->SMTPAutoTLS = false;
|
||||
$mail->SMTPSecure = false;
|
||||
$mail->SMTPOptions = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]];
|
||||
} else {
|
||||
$mail->SMTPSecure = $enc; // 'tls' | 'ssl'
|
||||
}
|
||||
|
||||
if ($provider === 'google_oauth' || $provider === 'microsoft_oauth') {
|
||||
// XOAUTH2
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->AuthType = 'XOAUTH2';
|
||||
$mail->Username = $username;
|
||||
|
||||
$access_token = resolveMailOauthAccessToken(
|
||||
$provider,
|
||||
trim($oauth_client_id),
|
||||
trim($oauth_client_secret),
|
||||
trim($oauth_tenant_id),
|
||||
trim($oauth_refresh_token),
|
||||
trim($oauth_access_token),
|
||||
trim($oauth_access_token_expires_at)
|
||||
);
|
||||
|
||||
if (empty($access_token)) {
|
||||
throw new Exception("Missing OAuth access token for XOAUTH2 SMTP.");
|
||||
}
|
||||
|
||||
$mail->setOAuth(new StaticTokenProvider($username, $access_token));
|
||||
} else {
|
||||
// Standard SMTP (with or without auth)
|
||||
$mail->SMTPAuth = !empty($username);
|
||||
$mail->Username = $username ?: '';
|
||||
$mail->Password = $password ?: '';
|
||||
}
|
||||
|
||||
// Recipients & content
|
||||
$mail->setFrom($from_email, $from_name);
|
||||
$mail->addAddress($to_email, $to_name);
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $html_body;
|
||||
|
||||
if (!empty($ics_str)) {
|
||||
$mail->addStringAttachment($ics_str, 'Scheduled_ticket.ics', 'base64', 'text/calendar');
|
||||
}
|
||||
|
||||
$mail->send();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* RECOVER: status = 1 (Sending) left behind by a run that died
|
||||
*
|
||||
* Nothing else in the codebase ever selects status 1, so without this a row
|
||||
* claimed by a run that was killed mid-send stays 'Sending' forever and is never
|
||||
* delivered. The cron lock above guarantees no other run of this script is in
|
||||
* progress, so any row still sitting at status 1 is by definition orphaned and
|
||||
* safe to reclaim. It is moved to failed rather than queued so it inherits the
|
||||
* retry pass's 30 minute backoff and attempt cap instead of retrying instantly.
|
||||
*
|
||||
* This can re-send a message that did go out but died before being marked sent.
|
||||
* That trade is deliberate: a duplicate is recoverable, an invoice that silently
|
||||
* never arrives is not.
|
||||
* ======================================================================= */
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = email_attempts + 1 WHERE email_status = 1");
|
||||
$orphaned_emails = mysqli_affected_rows($mysqli);
|
||||
if ($orphaned_emails > 0) {
|
||||
logApp("Cron-Mail-Queue", "warning", "Recovered $orphaned_emails email(s) left in a sending state by a previous run - queued for retry.");
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* SEND: status = 0 (Queued)
|
||||
* ======================================================================= */
|
||||
$sql_queue = mysqli_query($mysqli, "SELECT * FROM email_queue WHERE email_status = 0 AND email_queued_at <= NOW()");
|
||||
|
||||
if (mysqli_num_rows($sql_queue) > 0) {
|
||||
while ($rowq = mysqli_fetch_assoc($sql_queue)) {
|
||||
$email_id = (int)$rowq['email_id'];
|
||||
$email_from = $rowq['email_from'];
|
||||
$email_from_name = $rowq['email_from_name'];
|
||||
$email_recipient = $rowq['email_recipient'];
|
||||
$email_recipient_name = $rowq['email_recipient_name'];
|
||||
$email_subject = $rowq['email_subject'];
|
||||
$email_content = $rowq['email_content'];
|
||||
$email_ics_str = $rowq['email_cal_str'];
|
||||
|
||||
// Check sender
|
||||
if (!filter_var($email_from, FILTER_VALIDATE_EMAIL)) {
|
||||
$email_from_logging = escapeSql($rowq['email_from']);
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to send email #$email_id due to invalid sender address: $email_from_logging - check configuration in settings.");
|
||||
appNotify("Mail", "Failed to send email #$email_id due to invalid sender address");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Claim the row - the conditional UPDATE is the lock. If another run already took
|
||||
// this email, skip it rather than sending the client a second copy.
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 0");
|
||||
if (mysqli_affected_rows($mysqli) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Basic recipient syntax check
|
||||
if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
|
||||
$email_to_logging = escapeSql($email_recipient);
|
||||
$email_subject_logging = escapeSql($rowq['email_subject']);
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient address. Email subject was: $email_subject_logging");
|
||||
appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient address: Email subject was: $email_subject_logging");
|
||||
continue;
|
||||
}
|
||||
|
||||
// More intelligent recipient MX check (if not disabled with --no-mx-validation)
|
||||
$domain = escapeSql(substr($email_recipient, strpos($email_recipient, '@') + 1));
|
||||
if (!in_array('--no-mx-validation', $argv) && !checkdnsrr($domain, 'MX')) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = 99 WHERE email_id = $email_id");
|
||||
$email_to_logging = escapeSql($email_recipient);
|
||||
$email_subject_logging = escapeSql($rowq['email_subject']);
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_to_logging due to invalid recipient domain (no MX). Email subject was: $email_subject_logging");
|
||||
appNotify("Mail", "Failed to send email #$email_id to $email_to_logging due to invalid recipient domain (no MX): Email subject was: $email_subject_logging");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
sendQueueEmail(
|
||||
($config_smtp_provider ?: 'standard_smtp'),
|
||||
$config_smtp_host,
|
||||
(int)$config_smtp_port,
|
||||
(string)$config_smtp_encryption,
|
||||
(string)$config_smtp_username,
|
||||
(string)$config_smtp_password,
|
||||
(string)$email_from,
|
||||
(string)$email_from_name,
|
||||
(string)$email_recipient,
|
||||
(string)$email_recipient_name,
|
||||
(string)$email_subject,
|
||||
(string)$email_content,
|
||||
(string)$email_ics_str,
|
||||
(string)$config_mail_oauth_client_id,
|
||||
(string)$config_mail_oauth_client_secret,
|
||||
(string)$config_mail_oauth_tenant_id,
|
||||
(string)$config_mail_oauth_refresh_token,
|
||||
(string)$config_mail_oauth_access_token,
|
||||
(string)$config_mail_oauth_access_token_expires_at
|
||||
);
|
||||
|
||||
// Scrub the body on delivery - it can carry share decryption keys and temporary passwords
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = 1, email_content = '', email_cal_str = '' WHERE email_id = $email_id");
|
||||
|
||||
} catch (Exception $e) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = 1 WHERE email_id = $email_id");
|
||||
|
||||
$email_recipient_logging = escapeSql($rowq['email_recipient']);
|
||||
$email_subject_logging = escapeSql($rowq['email_subject']);
|
||||
$err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "...";
|
||||
|
||||
appNotify("Cron-Mail-Queue", "Failed to send email #$email_id to $email_recipient_logging");
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to send email: $email_id to $email_recipient_logging regarding $email_subject_logging. $err");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** =======================================================================
|
||||
* RETRIES: status = 2 (Failed), attempts < 4, wait 30 min
|
||||
* NOTE: Backoff is `email_failed_at <= NOW() - INTERVAL 30 MINUTE`
|
||||
* =======================================================================
|
||||
*/
|
||||
$sql_failed_queue = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT * FROM email_queue
|
||||
WHERE email_status = 2
|
||||
AND email_attempts < 4
|
||||
AND email_failed_at <= NOW() - INTERVAL 30 MINUTE"
|
||||
);
|
||||
|
||||
if (mysqli_num_rows($sql_failed_queue) > 0) {
|
||||
while ($rowf = mysqli_fetch_assoc($sql_failed_queue)) {
|
||||
$email_id = (int)$rowf['email_id'];
|
||||
$email_from = $rowf['email_from'];
|
||||
$email_from_name = $rowf['email_from_name'];
|
||||
$email_recipient = $rowf['email_recipient'];
|
||||
$email_recipient_name = $rowf['email_recipient_name'];
|
||||
$email_subject = $rowf['email_subject'];
|
||||
$email_content = $rowf['email_content'];
|
||||
$email_ics_str = $rowf['email_cal_str'];
|
||||
$email_attempts = (int)$rowf['email_attempts'] + 1;
|
||||
|
||||
// Claim the row - same lock as the send path, from the failed state this time.
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 1 WHERE email_id = $email_id AND email_status = 2");
|
||||
if (mysqli_affected_rows($mysqli) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!filter_var($email_recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_attempts = $email_attempts WHERE email_id = $email_id");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
sendQueueEmail(
|
||||
($config_smtp_provider ?: 'standard_smtp'),
|
||||
$config_smtp_host,
|
||||
(int)$config_smtp_port,
|
||||
(string)$config_smtp_encryption,
|
||||
(string)$config_smtp_username,
|
||||
(string)$config_smtp_password,
|
||||
(string)$email_from,
|
||||
(string)$email_from_name,
|
||||
(string)$email_recipient,
|
||||
(string)$email_recipient_name,
|
||||
(string)$email_subject,
|
||||
(string)$email_content,
|
||||
(string)$email_ics_str,
|
||||
(string)$config_mail_oauth_client_id,
|
||||
(string)$config_mail_oauth_client_secret,
|
||||
(string)$config_mail_oauth_tenant_id,
|
||||
(string)$config_mail_oauth_refresh_token,
|
||||
(string)$config_mail_oauth_access_token,
|
||||
(string)$config_mail_oauth_access_token_expires_at
|
||||
);
|
||||
|
||||
// Scrub the body on delivery - it can carry share decryption keys and temporary passwords
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 3, email_sent_at = NOW(), email_attempts = $email_attempts, email_content = '', email_cal_str = '' WHERE email_id = $email_id");
|
||||
|
||||
} catch (Exception $e) {
|
||||
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = $email_attempts WHERE email_id = $email_id");
|
||||
|
||||
$email_recipient_logging = escapeSql($rowf['email_recipient']);
|
||||
$email_subject_logging = escapeSql($rowf['email_subject']);
|
||||
$err = substr("Mailer Error: " . $e->getMessage(), 0, 100) . "...";
|
||||
|
||||
logApp("Cron-Mail-Queue", "Error", "Failed to re-send email #$email_id to $email_recipient_logging regarding $email_subject_logging. $err");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
824
js/app.js
824
js/app.js
@@ -1,412 +1,412 @@
|
||||
$(document).ready(function() {
|
||||
// Prevents resubmit on forms
|
||||
if (window.history.replaceState) {
|
||||
window.history.replaceState(null, null, window.location.href);
|
||||
}
|
||||
|
||||
// Slide alert up after 4 secs
|
||||
$("#alert").fadeTo(5000, 500).slideUp(500, function() {
|
||||
$("#alert").slideUp(500);
|
||||
});
|
||||
|
||||
// Initialize Select2 Elements
|
||||
$('.select2').select2({
|
||||
theme: 'bootstrap4',
|
||||
});
|
||||
|
||||
// Initialize TinyMCE
|
||||
tinymce.init({
|
||||
selector: '.tinymce-simple',
|
||||
browser_spellcheck: true,
|
||||
contextmenu: false,
|
||||
resize: true,
|
||||
min_height: 300,
|
||||
max_height: 600,
|
||||
promotion: false,
|
||||
branding: false,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
toolbar: [
|
||||
{ name: 'styles', items: ['styles'] },
|
||||
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
|
||||
{ name: 'link', items: ['link'] },
|
||||
{ name: 'lists', items: ['bullist', 'numlist'] },
|
||||
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
|
||||
{ name: 'indentation', items: ['outdent', 'indent'] },
|
||||
{ name: 'table', items: ['table'] },
|
||||
{ name: 'extra', items: ['code', 'fullscreen'] }
|
||||
],
|
||||
mobile: {
|
||||
menubar: false,
|
||||
plugins: 'autosave lists autolink',
|
||||
toolbar: 'bold italic styles'
|
||||
},
|
||||
convert_urls: false,
|
||||
plugins: 'link image lists table code codesample fullscreen autoresize',
|
||||
setup: function (editor) {
|
||||
editor.on('init', function() {
|
||||
window.onbeforeunload = function() {
|
||||
// If editor is dirty AND not inside a visible modal → warn
|
||||
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
|
||||
if (!inVisibleModal && editor.isDirty()) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
};
|
||||
|
||||
// When the modal closes, mark editor clean
|
||||
const modal = editor.getContainer()?.closest('.modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
editor.undoManager.clear();
|
||||
editor.setDirty(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
license_key: 'gpl'
|
||||
});
|
||||
|
||||
// Initialize TinyMCE with AI
|
||||
tinymce.init({
|
||||
selector: '.tinymce',
|
||||
browser_spellcheck: true,
|
||||
contextmenu: false,
|
||||
resize: true,
|
||||
min_height: 300,
|
||||
max_height: 600,
|
||||
promotion: false,
|
||||
branding: false,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
toolbar: [
|
||||
{ name: 'styles', items: ['styles'] },
|
||||
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
|
||||
{ name: 'link', items: ['link'] },
|
||||
{ name: 'lists', items: ['bullist', 'numlist'] },
|
||||
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
|
||||
{ name: 'indentation', items: ['outdent', 'indent'] },
|
||||
{ name: 'table', items: ['table'] },
|
||||
{ name: 'extra', items: ['code', 'fullscreen'] },
|
||||
{ name: 'ai', items: ['reword', 'undo', 'redo'] }
|
||||
],
|
||||
mobile: {
|
||||
menubar: false,
|
||||
plugins: 'autosave lists autolink',
|
||||
toolbar: 'bold italic styles'
|
||||
},
|
||||
convert_urls: false,
|
||||
plugins: 'link image lists table code codesample fullscreen autoresize',
|
||||
license_key: 'gpl',
|
||||
setup: function(editor) {
|
||||
editor.on('init', function() {
|
||||
window.onbeforeunload = function() {
|
||||
// If editor is dirty AND not inside a visible modal → warn
|
||||
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
|
||||
if (!inVisibleModal && editor.isDirty()) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
};
|
||||
|
||||
// When the modal closes, mark editor clean
|
||||
const modal = editor.getContainer()?.closest('.modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
editor.undoManager.clear();
|
||||
editor.setDirty(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var rewordButtonApi;
|
||||
|
||||
editor.ui.registry.addButton('reword', {
|
||||
icon: 'ai',
|
||||
tooltip: 'Reword Text',
|
||||
onAction: function() {
|
||||
var content = editor.getContent();
|
||||
|
||||
// Disable the Reword button
|
||||
rewordButtonApi.setEnabled(false);
|
||||
|
||||
// Show the progress indicator
|
||||
editor.setProgressState(true);
|
||||
|
||||
fetch('ajax.php?ai_reword', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: content }),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
editor.undoManager.transact(function() {
|
||||
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
|
||||
});
|
||||
|
||||
editor.setProgressState(false);
|
||||
rewordButtonApi.setEnabled(true);
|
||||
|
||||
editor.notificationManager.open({
|
||||
text: 'Text reworded successfully!',
|
||||
type: 'success',
|
||||
timeout: 3000
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
editor.setProgressState(false);
|
||||
rewordButtonApi.setEnabled(true);
|
||||
editor.notificationManager.open({
|
||||
text: 'An error occurred while rewording the text.',
|
||||
type: 'error',
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
},
|
||||
onSetup: function(buttonApi) {
|
||||
rewordButtonApi = buttonApi;
|
||||
return function() {};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize TinyMCE AI for Tickets
|
||||
tinymce.init({
|
||||
selector: '.tinymceTicket',
|
||||
browser_spellcheck: true,
|
||||
contextmenu: false,
|
||||
resize: true,
|
||||
min_height: 200,
|
||||
max_height: 600,
|
||||
promotion: false,
|
||||
branding: false,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
toolbar: [
|
||||
{ name: 'styles', items: ['styles'] },
|
||||
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
|
||||
{ name: 'link', items: ['link'] },
|
||||
{ name: 'lists', items: ['bullist', 'numlist'] },
|
||||
{ name: 'indentation', items: ['outdent', 'indent'] },
|
||||
{ name: 'ai', items: ['reword', 'undo', 'redo'] },
|
||||
{ name: 'custom', items: ['redactButton'] },
|
||||
{ name: 'code', items: ['code'] },
|
||||
],
|
||||
mobile: {
|
||||
menubar: false,
|
||||
toolbar: [
|
||||
{ name: 'styles', items: ['styles'] },
|
||||
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
|
||||
{ name: 'link', items: ['link'] },
|
||||
{ name: 'lists', items: ['bullist', 'numlist'] },
|
||||
{ name: 'indentation', items: ['outdent', 'indent'] },
|
||||
{ name: 'ai', items: ['reword', 'undo', 'redo'] },
|
||||
{ name: 'custom', items: ['redactButton'] },
|
||||
{ name: 'code', items: ['code'] },
|
||||
],
|
||||
},
|
||||
convert_urls: false,
|
||||
plugins: 'link image lists table code codesample fullscreen autoresize code',
|
||||
license_key: 'gpl',
|
||||
setup: function(editor) {
|
||||
editor.on('init', function() {
|
||||
window.onbeforeunload = function() {
|
||||
// If editor is dirty AND not inside a visible modal → warn
|
||||
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
|
||||
if (!inVisibleModal && editor.isDirty()) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
};
|
||||
|
||||
// When the modal closes, mark editor clean
|
||||
const modal = editor.getContainer()?.closest('.modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
editor.undoManager.clear();
|
||||
editor.setDirty(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var rewordButtonApi;
|
||||
|
||||
editor.ui.registry.addButton('reword', {
|
||||
icon: 'ai',
|
||||
tooltip: 'Reword Text',
|
||||
onAction: function() {
|
||||
var content = editor.getContent();
|
||||
rewordButtonApi.setEnabled(false);
|
||||
editor.setProgressState(true);
|
||||
|
||||
fetch('ajax.php?ai_reword', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content }),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
editor.undoManager.transact(function() {
|
||||
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
|
||||
});
|
||||
editor.setProgressState(false);
|
||||
rewordButtonApi.setEnabled(true);
|
||||
editor.notificationManager.open({
|
||||
text: 'Text reworded successfully!',
|
||||
type: 'success',
|
||||
timeout: 3000
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
editor.setProgressState(false);
|
||||
rewordButtonApi.setEnabled(true);
|
||||
editor.notificationManager.open({
|
||||
text: 'An error occurred while rewording the text.',
|
||||
type: 'error',
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
},
|
||||
onSetup: function(buttonApi) {
|
||||
rewordButtonApi = buttonApi;
|
||||
return function() {};
|
||||
}
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('redactButton', {
|
||||
icon: 'permanent-pen',
|
||||
tooltip: 'Redact Text',
|
||||
onAction: function() {
|
||||
var selectedText = editor.selection.getContent({ format: 'text' });
|
||||
if (selectedText) {
|
||||
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
|
||||
editor.selection.setContent(newContent);
|
||||
} else {
|
||||
alert('Please select a word to redact');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize TinyMCE Redact-only
|
||||
tinymce.init({
|
||||
selector: '.tinymceRedact',
|
||||
browser_spellcheck: true,
|
||||
contextmenu: false,
|
||||
resize: true,
|
||||
min_height: 300,
|
||||
max_height: 600,
|
||||
promotion: false,
|
||||
branding: false,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
toolbar: 'redactButton',
|
||||
mobile: {
|
||||
menubar: false,
|
||||
plugins: 'autosave lists autolink',
|
||||
toolbar: 'redactButton'
|
||||
},
|
||||
convert_urls: false,
|
||||
plugins: 'link image lists table code fullscreen autoresize',
|
||||
license_key: 'gpl',
|
||||
setup: function(editor) {
|
||||
|
||||
editor.on('init', function() {
|
||||
window.onbeforeunload = function() {
|
||||
// If editor is dirty AND not inside a visible modal → warn
|
||||
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
|
||||
if (!inVisibleModal && editor.isDirty()) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
};
|
||||
|
||||
// When the modal closes, mark editor clean
|
||||
const modal = editor.getContainer()?.closest('.modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
editor.undoManager.clear();
|
||||
editor.setDirty(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
editor.on('keydown', function(e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('redactButton', {
|
||||
icon: 'permanent-pen',
|
||||
tooltip: 'Redact',
|
||||
text: 'REDACT',
|
||||
onAction: function() {
|
||||
var selectedText = editor.selection.getContent({ format: 'text' });
|
||||
if (selectedText) {
|
||||
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
|
||||
editor.selection.setContent(newContent);
|
||||
} else {
|
||||
alert('Please select a word to redact');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DateTime
|
||||
$('.datetimepicker').datetimepicker();
|
||||
|
||||
// Data Input Mask
|
||||
$('[data-mask]').inputmask();
|
||||
|
||||
// ClipboardJS fix for Bootstrap modals
|
||||
$.fn.modal.Constructor.prototype._enforceFocus = function() {};
|
||||
|
||||
// Tooltip
|
||||
$('button').tooltip({
|
||||
trigger: 'click',
|
||||
placement: 'bottom'
|
||||
});
|
||||
|
||||
function setTooltip(btn, message) {
|
||||
$(btn).tooltip('hide')
|
||||
.attr('data-original-title', message)
|
||||
.tooltip('show');
|
||||
}
|
||||
|
||||
function hideTooltip(btn) {
|
||||
setTimeout(function() {
|
||||
$(btn).tooltip('hide');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
var clipboard = new ClipboardJS('.clipboardjs');
|
||||
|
||||
clipboard.on('success', function(e) {
|
||||
setTooltip(e.trigger, 'Copied!');
|
||||
hideTooltip(e.trigger);
|
||||
});
|
||||
|
||||
clipboard.on('error', function(e) {
|
||||
setTooltip(e.trigger, 'Failed!');
|
||||
hideTooltip(e.trigger);
|
||||
});
|
||||
|
||||
// Enable Popovers
|
||||
$(function() {
|
||||
$('[data-toggle="popover"]').popover();
|
||||
});
|
||||
|
||||
// Data Tables
|
||||
new DataTable('.dataTables');
|
||||
});
|
||||
$(document).ready(function() {
|
||||
// Prevents resubmit on forms
|
||||
if (window.history.replaceState) {
|
||||
window.history.replaceState(null, null, window.location.href);
|
||||
}
|
||||
|
||||
// Slide alert up after 4 secs
|
||||
$("#alert").fadeTo(5000, 500).slideUp(500, function() {
|
||||
$("#alert").slideUp(500);
|
||||
});
|
||||
|
||||
// Initialize Select2 Elements
|
||||
$('.select2').select2({
|
||||
theme: 'bootstrap4',
|
||||
});
|
||||
|
||||
// Initialize TinyMCE
|
||||
tinymce.init({
|
||||
selector: '.tinymce-simple',
|
||||
browser_spellcheck: true,
|
||||
contextmenu: false,
|
||||
resize: true,
|
||||
min_height: 300,
|
||||
max_height: 600,
|
||||
promotion: false,
|
||||
branding: false,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
toolbar: [
|
||||
{ name: 'styles', items: ['styles'] },
|
||||
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
|
||||
{ name: 'link', items: ['link'] },
|
||||
{ name: 'lists', items: ['bullist', 'numlist'] },
|
||||
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
|
||||
{ name: 'indentation', items: ['outdent', 'indent'] },
|
||||
{ name: 'table', items: ['table'] },
|
||||
{ name: 'extra', items: ['code', 'fullscreen'] }
|
||||
],
|
||||
mobile: {
|
||||
menubar: false,
|
||||
plugins: 'autosave lists autolink',
|
||||
toolbar: 'bold italic styles'
|
||||
},
|
||||
convert_urls: false,
|
||||
plugins: 'link image lists table code codesample fullscreen autoresize',
|
||||
setup: function (editor) {
|
||||
editor.on('init', function() {
|
||||
window.onbeforeunload = function() {
|
||||
// If editor is dirty AND not inside a visible modal → warn
|
||||
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
|
||||
if (!inVisibleModal && editor.isDirty()) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
};
|
||||
|
||||
// When the modal closes, mark editor clean
|
||||
const modal = editor.getContainer()?.closest('.modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
editor.undoManager.clear();
|
||||
editor.setDirty(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
license_key: 'gpl'
|
||||
});
|
||||
|
||||
// Initialize TinyMCE with AI
|
||||
tinymce.init({
|
||||
selector: '.tinymce',
|
||||
browser_spellcheck: true,
|
||||
contextmenu: false,
|
||||
resize: true,
|
||||
min_height: 300,
|
||||
max_height: 600,
|
||||
promotion: false,
|
||||
branding: false,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
toolbar: [
|
||||
{ name: 'styles', items: ['styles'] },
|
||||
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
|
||||
{ name: 'link', items: ['link'] },
|
||||
{ name: 'lists', items: ['bullist', 'numlist'] },
|
||||
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
|
||||
{ name: 'indentation', items: ['outdent', 'indent'] },
|
||||
{ name: 'table', items: ['table'] },
|
||||
{ name: 'extra', items: ['code', 'fullscreen'] },
|
||||
{ name: 'ai', items: ['reword', 'undo', 'redo'] }
|
||||
],
|
||||
mobile: {
|
||||
menubar: false,
|
||||
plugins: 'autosave lists autolink',
|
||||
toolbar: 'bold italic styles'
|
||||
},
|
||||
convert_urls: false,
|
||||
plugins: 'link image lists table code codesample fullscreen autoresize',
|
||||
license_key: 'gpl',
|
||||
setup: function(editor) {
|
||||
editor.on('init', function() {
|
||||
window.onbeforeunload = function() {
|
||||
// If editor is dirty AND not inside a visible modal → warn
|
||||
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
|
||||
if (!inVisibleModal && editor.isDirty()) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
};
|
||||
|
||||
// When the modal closes, mark editor clean
|
||||
const modal = editor.getContainer()?.closest('.modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
editor.undoManager.clear();
|
||||
editor.setDirty(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var rewordButtonApi;
|
||||
|
||||
editor.ui.registry.addButton('reword', {
|
||||
icon: 'ai',
|
||||
tooltip: 'Reword Text',
|
||||
onAction: function() {
|
||||
var content = editor.getContent();
|
||||
|
||||
// Disable the Reword button
|
||||
rewordButtonApi.setEnabled(false);
|
||||
|
||||
// Show the progress indicator
|
||||
editor.setProgressState(true);
|
||||
|
||||
fetch('ajax.php?ai_reword', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: content }),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
editor.undoManager.transact(function() {
|
||||
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
|
||||
});
|
||||
|
||||
editor.setProgressState(false);
|
||||
rewordButtonApi.setEnabled(true);
|
||||
|
||||
editor.notificationManager.open({
|
||||
text: 'Text reworded successfully!',
|
||||
type: 'success',
|
||||
timeout: 3000
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
editor.setProgressState(false);
|
||||
rewordButtonApi.setEnabled(true);
|
||||
editor.notificationManager.open({
|
||||
text: 'An error occurred while rewording the text.',
|
||||
type: 'error',
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
},
|
||||
onSetup: function(buttonApi) {
|
||||
rewordButtonApi = buttonApi;
|
||||
return function() {};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize TinyMCE AI for Tickets
|
||||
tinymce.init({
|
||||
selector: '.tinymceTicket',
|
||||
browser_spellcheck: true,
|
||||
contextmenu: false,
|
||||
resize: true,
|
||||
min_height: 200,
|
||||
max_height: 600,
|
||||
promotion: false,
|
||||
branding: false,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
toolbar: [
|
||||
{ name: 'styles', items: ['styles'] },
|
||||
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
|
||||
{ name: 'link', items: ['link'] },
|
||||
{ name: 'lists', items: ['bullist', 'numlist'] },
|
||||
{ name: 'indentation', items: ['outdent', 'indent'] },
|
||||
{ name: 'ai', items: ['reword', 'undo', 'redo'] },
|
||||
{ name: 'custom', items: ['redactButton'] },
|
||||
{ name: 'code', items: ['code'] },
|
||||
],
|
||||
mobile: {
|
||||
menubar: false,
|
||||
toolbar: [
|
||||
{ name: 'styles', items: ['styles'] },
|
||||
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
|
||||
{ name: 'link', items: ['link'] },
|
||||
{ name: 'lists', items: ['bullist', 'numlist'] },
|
||||
{ name: 'indentation', items: ['outdent', 'indent'] },
|
||||
{ name: 'ai', items: ['reword', 'undo', 'redo'] },
|
||||
{ name: 'custom', items: ['redactButton'] },
|
||||
{ name: 'code', items: ['code'] },
|
||||
],
|
||||
},
|
||||
convert_urls: false,
|
||||
plugins: 'link image lists table code codesample fullscreen autoresize code',
|
||||
license_key: 'gpl',
|
||||
setup: function(editor) {
|
||||
editor.on('init', function() {
|
||||
window.onbeforeunload = function() {
|
||||
// If editor is dirty AND not inside a visible modal → warn
|
||||
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
|
||||
if (!inVisibleModal && editor.isDirty()) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
};
|
||||
|
||||
// When the modal closes, mark editor clean
|
||||
const modal = editor.getContainer()?.closest('.modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
editor.undoManager.clear();
|
||||
editor.setDirty(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var rewordButtonApi;
|
||||
|
||||
editor.ui.registry.addButton('reword', {
|
||||
icon: 'ai',
|
||||
tooltip: 'Reword Text',
|
||||
onAction: function() {
|
||||
var content = editor.getContent();
|
||||
rewordButtonApi.setEnabled(false);
|
||||
editor.setProgressState(true);
|
||||
|
||||
fetch('ajax.php?ai_reword', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content }),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
editor.undoManager.transact(function() {
|
||||
editor.setContent(data.rewordedText || 'Error: Could not reword the text.');
|
||||
});
|
||||
editor.setProgressState(false);
|
||||
rewordButtonApi.setEnabled(true);
|
||||
editor.notificationManager.open({
|
||||
text: 'Text reworded successfully!',
|
||||
type: 'success',
|
||||
timeout: 3000
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
editor.setProgressState(false);
|
||||
rewordButtonApi.setEnabled(true);
|
||||
editor.notificationManager.open({
|
||||
text: 'An error occurred while rewording the text.',
|
||||
type: 'error',
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
},
|
||||
onSetup: function(buttonApi) {
|
||||
rewordButtonApi = buttonApi;
|
||||
return function() {};
|
||||
}
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('redactButton', {
|
||||
icon: 'permanent-pen',
|
||||
tooltip: 'Redact Text',
|
||||
onAction: function() {
|
||||
var selectedText = editor.selection.getContent({ format: 'text' });
|
||||
if (selectedText) {
|
||||
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
|
||||
editor.selection.setContent(newContent);
|
||||
} else {
|
||||
alert('Please select a word to redact');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize TinyMCE Redact-only
|
||||
tinymce.init({
|
||||
selector: '.tinymceRedact',
|
||||
browser_spellcheck: true,
|
||||
contextmenu: false,
|
||||
resize: true,
|
||||
min_height: 300,
|
||||
max_height: 600,
|
||||
promotion: false,
|
||||
branding: false,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
toolbar: 'redactButton',
|
||||
mobile: {
|
||||
menubar: false,
|
||||
plugins: 'autosave lists autolink',
|
||||
toolbar: 'redactButton'
|
||||
},
|
||||
convert_urls: false,
|
||||
plugins: 'link image lists table code fullscreen autoresize',
|
||||
license_key: 'gpl',
|
||||
setup: function(editor) {
|
||||
|
||||
editor.on('init', function() {
|
||||
window.onbeforeunload = function() {
|
||||
// If editor is dirty AND not inside a visible modal → warn
|
||||
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
|
||||
if (!inVisibleModal && editor.isDirty()) {
|
||||
return "You have unsaved changes. Are you sure you want to leave?";
|
||||
}
|
||||
};
|
||||
|
||||
// When the modal closes, mark editor clean
|
||||
const modal = editor.getContainer()?.closest('.modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
editor.undoManager.clear();
|
||||
editor.setDirty(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
editor.on('keydown', function(e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('redactButton', {
|
||||
icon: 'permanent-pen',
|
||||
tooltip: 'Redact',
|
||||
text: 'REDACT',
|
||||
onAction: function() {
|
||||
var selectedText = editor.selection.getContent({ format: 'text' });
|
||||
if (selectedText) {
|
||||
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
|
||||
editor.selection.setContent(newContent);
|
||||
} else {
|
||||
alert('Please select a word to redact');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DateTime
|
||||
$('.datetimepicker').datetimepicker();
|
||||
|
||||
// Data Input Mask
|
||||
$('[data-mask]').inputmask();
|
||||
|
||||
// ClipboardJS fix for Bootstrap modals
|
||||
$.fn.modal.Constructor.prototype._enforceFocus = function() {};
|
||||
|
||||
// Tooltip
|
||||
$('button').tooltip({
|
||||
trigger: 'click',
|
||||
placement: 'bottom'
|
||||
});
|
||||
|
||||
function setTooltip(btn, message) {
|
||||
$(btn).tooltip('hide')
|
||||
.attr('data-original-title', message)
|
||||
.tooltip('show');
|
||||
}
|
||||
|
||||
function hideTooltip(btn) {
|
||||
setTimeout(function() {
|
||||
$(btn).tooltip('hide');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
var clipboard = new ClipboardJS('.clipboardjs');
|
||||
|
||||
clipboard.on('success', function(e) {
|
||||
setTooltip(e.trigger, 'Copied!');
|
||||
hideTooltip(e.trigger);
|
||||
});
|
||||
|
||||
clipboard.on('error', function(e) {
|
||||
setTooltip(e.trigger, 'Failed!');
|
||||
hideTooltip(e.trigger);
|
||||
});
|
||||
|
||||
// Enable Popovers
|
||||
$(function() {
|
||||
$('[data-toggle="popover"]').popover();
|
||||
});
|
||||
|
||||
// Data Tables
|
||||
new DataTable('.dataTables');
|
||||
});
|
||||
|
||||
30
normalize_eol.sh
Normal file
30
normalize_eol.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ITFlow - one-shot CRLF -> LF normalization.
|
||||
#
|
||||
# Converts every tracked text file outside libs/ to LF. Vendored libraries are
|
||||
# left byte-for-byte as shipped upstream (CONTRIBUTING.md: libs/ is replaced
|
||||
# wholesale, never edited), and binary assets are skipped outright.
|
||||
#
|
||||
# Run once, from the repo root, alongside adding .gitattributes. After that
|
||||
# .gitattributes keeps new files in line and this script should be a no-op.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
BINARY_RE='\.(png|gif|jpg|jpeg|webp|ico|icc|woff|woff2|ttf|eot|crt|ser|z)$'
|
||||
|
||||
mapfile -t candidates < <(git ls-files | grep -v '^libs/' | grep -viE "$BINARY_RE")
|
||||
|
||||
changed=0
|
||||
for f in "${candidates[@]}"; do
|
||||
[ -f "$f" ] || continue
|
||||
# only touch files that actually contain a CR
|
||||
if LC_ALL=C grep -qU $'\r' "$f" 2>/dev/null; then
|
||||
LC_ALL=C sed -i 's/\r$//' "$f"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "normalized $changed file(s)"
|
||||
30
scripts/normalize_eol.sh
Executable file
30
scripts/normalize_eol.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ITFlow - one-shot CRLF -> LF normalization.
|
||||
#
|
||||
# Converts every tracked text file outside libs/ to LF. Vendored libraries are
|
||||
# left byte-for-byte as shipped upstream (CONTRIBUTING.md: libs/ is replaced
|
||||
# wholesale, never edited), and binary assets are skipped outright.
|
||||
#
|
||||
# Run once, from the repo root, alongside adding .gitattributes. After that
|
||||
# .gitattributes keeps new files in line and this script should be a no-op.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
BINARY_RE='\.(png|gif|jpg|jpeg|webp|ico|icc|woff|woff2|ttf|eot|crt|ser|z)$'
|
||||
|
||||
mapfile -t candidates < <(git ls-files | grep -v '^libs/' | grep -viE "$BINARY_RE")
|
||||
|
||||
changed=0
|
||||
for f in "${candidates[@]}"; do
|
||||
[ -f "$f" ] || continue
|
||||
# only touch files that actually contain a CR
|
||||
if LC_ALL=C grep -qU $'\r' "$f" 2>/dev/null; then
|
||||
LC_ALL=C sed -i 's/\r$//' "$f"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "normalized $changed file(s)"
|
||||
Reference in New Issue
Block a user