Merge pull request #1261 from cs2000/develop

Add full Microsoft 365 and Google OAuth mail support
This commit is contained in:
Johnny 2026-02-05 14:04:22 -05:00 committed by GitHub
commit 4d895a56e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 5302 additions and 4704 deletions

View File

@ -0,0 +1,103 @@
<?php
require_once "../config.php";
require_once "../functions.php";
require_once "../includes/check_login.php";
$settings_mail_path = '/admin/settings_mail.php';
if (!isset($session_is_admin) || !$session_is_admin) {
flash_alert("Admin access required.", 'error');
redirect($settings_mail_path);
}
$state = sanitizeInput($_GET['state'] ?? '');
$code = $_GET['code'] ?? '';
$error = sanitizeInput($_GET['error'] ?? '');
$error_description = sanitizeInput($_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)";
}
flash_alert($msg, 'error');
redirect($settings_mail_path);
}
if (empty($state) || empty($code) || empty($session_state) || !hash_equals($session_state, $state) || time() > $session_state_expires) {
flash_alert("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)) {
flash_alert("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";
flash_alert("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'])) {
flash_alert("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
");
logAction("Settings", "Edit", "$session_name completed Microsoft OAuth connect flow for mail settings");
flash_alert("Microsoft OAuth connected successfully. Token expires at $expires_at.");
redirect($settings_mail_path);

View File

@ -2,6 +2,79 @@
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
if (!defined('MICROSOFT_OAUTH_BASE_URL')) {
define('MICROSOFT_OAUTH_BASE_URL', 'https://login.microsoftonline.com/');
}
if (isset($_POST['oauth_connect_microsoft_mail'])) {
validateCSRFToken($_POST['csrf_token']);
// Save current IMAP/OAuth form values first so auth flow always uses latest inputs.
$config_imap_provider = sanitizeInput($_POST['config_imap_provider'] ?? '');
$config_imap_username = sanitizeInput($_POST['config_imap_username'] ?? '');
$config_mail_oauth_client_id = sanitizeInput($_POST['config_mail_oauth_client_id'] ?? '');
$config_mail_oauth_client_secret = sanitizeInput($_POST['config_mail_oauth_client_secret'] ?? '');
$config_mail_oauth_tenant_id = sanitizeInput($_POST['config_mail_oauth_tenant_id'] ?? '');
$config_mail_oauth_refresh_token = sanitizeInput($_POST['config_mail_oauth_refresh_token'] ?? '');
$config_mail_oauth_access_token = sanitizeInput($_POST['config_mail_oauth_access_token'] ?? '');
mysqli_query($mysqli, "UPDATE settings SET
config_imap_provider = '$config_imap_provider',
config_imap_username = '$config_imap_username',
config_mail_oauth_client_id = '$config_mail_oauth_client_id',
config_mail_oauth_client_secret = '$config_mail_oauth_client_secret',
config_mail_oauth_tenant_id = '$config_mail_oauth_tenant_id',
config_mail_oauth_refresh_token = '$config_mail_oauth_refresh_token',
config_mail_oauth_access_token = '$config_mail_oauth_access_token'
WHERE company_id = 1
");
if ($config_imap_provider !== 'microsoft_oauth') {
flash_alert("Please set IMAP Provider to Microsoft 365 (OAuth) before connecting.", 'error');
redirect();
}
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_tenant_id)) {
flash_alert("Missing Microsoft OAuth settings. Please provide Client ID, Client Secret, and Tenant ID first.", 'error');
redirect();
}
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';
try {
$state = bin2hex(random_bytes(32));
} catch (Throwable $e) {
$state = sha1(uniqid((string) mt_rand(), true));
}
$_SESSION['mail_oauth_state'] = $state;
$_SESSION['mail_oauth_state_expires_at'] = time() + 600;
$scope = 'offline_access openid profile https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send';
$authorize_url = MICROSOFT_OAUTH_BASE_URL . rawurlencode($config_mail_oauth_tenant_id) . '/oauth2/v2.0/authorize?'
. http_build_query([
'client_id' => $config_mail_oauth_client_id,
'response_type' => 'code',
'redirect_uri' => $redirect_uri,
'response_mode' => 'query',
'scope' => $scope,
'state' => $state,
'prompt' => 'consent',
], '', '&', PHP_QUERY_RFC3986);
logAction("Settings", "Edit", "$session_name started Microsoft OAuth connect flow for mail settings");
redirect($authorize_url);
}
if (isset($_POST['edit_mail_smtp_settings'])) { if (isset($_POST['edit_mail_smtp_settings'])) {
validateCSRFToken($_POST['csrf_token']); validateCSRFToken($_POST['csrf_token']);
@ -163,12 +236,145 @@ if (isset($_POST['test_email_imap'])) {
validateCSRFToken($_POST['csrf_token']); validateCSRFToken($_POST['csrf_token']);
$provider = sanitizeInput($config_imap_provider ?? '');
$host = $config_imap_host; $host = $config_imap_host;
$port = (int) $config_imap_port; $port = (int) $config_imap_port;
$encryption = strtolower(trim($config_imap_encryption)); // e.g. "ssl", "tls", "none" $encryption = strtolower(trim($config_imap_encryption)); // e.g. "ssl", "tls", "none"
$username = $config_imap_username; $username = $config_imap_username;
$password = $config_imap_password; $password = $config_imap_password;
// Shared OAuth fields
$config_mail_oauth_client_id = $config_mail_oauth_client_id ?? '';
$config_mail_oauth_client_secret = $config_mail_oauth_client_secret ?? '';
$config_mail_oauth_tenant_id = $config_mail_oauth_tenant_id ?? '';
$config_mail_oauth_refresh_token = $config_mail_oauth_refresh_token ?? '';
$config_mail_oauth_access_token = $config_mail_oauth_access_token ?? '';
$config_mail_oauth_access_token_expires_at = $config_mail_oauth_access_token_expires_at ?? '';
$is_oauth = ($provider === 'google_oauth' || $provider === 'microsoft_oauth');
if ($provider === 'google_oauth') {
if (empty($host)) {
$host = 'imap.gmail.com';
}
if (empty($port)) {
$port = 993;
}
if (empty($encryption)) {
$encryption = 'ssl';
}
} elseif ($provider === 'microsoft_oauth') {
if (empty($host)) {
$host = 'outlook.office365.com';
}
if (empty($port)) {
$port = 993;
}
if (empty($encryption)) {
$encryption = 'ssl';
}
}
if (empty($host) || empty($port) || empty($username)) {
flash_alert("<strong>IMAP connection failed:</strong> Missing host, port, or username.", 'error');
redirect();
}
$token_is_expired = function (?string $expires_at): bool {
if (empty($expires_at)) {
return true;
}
$ts = strtotime($expires_at);
if ($ts === false) {
return true;
}
return ($ts - 60) <= time();
};
$http_form_post = function (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,
];
};
if ($is_oauth) {
if (!empty($config_mail_oauth_access_token) && !$token_is_expired($config_mail_oauth_access_token_expires_at)) {
$password = $config_mail_oauth_access_token;
} else {
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_refresh_token)) {
flash_alert("<strong>IMAP OAuth failed:</strong> Missing OAuth client credentials or refresh token.", 'error');
redirect();
}
if ($provider === 'google_oauth') {
$response = $http_form_post('https://oauth2.googleapis.com/token', [
'client_id' => $config_mail_oauth_client_id,
'client_secret' => $config_mail_oauth_client_secret,
'refresh_token' => $config_mail_oauth_refresh_token,
'grant_type' => 'refresh_token',
]);
} else {
if (empty($config_mail_oauth_tenant_id)) {
flash_alert("<strong>IMAP OAuth failed:</strong> Microsoft tenant ID is required.", 'error');
redirect();
}
$token_url = MICROSOFT_OAUTH_BASE_URL . rawurlencode($config_mail_oauth_tenant_id) . "/oauth2/v2.0/token";
$response = $http_form_post($token_url, [
'client_id' => $config_mail_oauth_client_id,
'client_secret' => $config_mail_oauth_client_secret,
'refresh_token' => $config_mail_oauth_refresh_token,
'grant_type' => 'refresh_token',
]);
}
if (!$response['ok']) {
flash_alert("<strong>IMAP OAuth failed:</strong> Could not refresh access token.", 'error');
redirect();
}
$json = json_decode($response['body'], true);
if (!is_array($json) || empty($json['access_token'])) {
flash_alert("<strong>IMAP OAuth failed:</strong> Token response did not include an access token.", 'error');
redirect();
}
$password = $json['access_token'];
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
$refresh_token_to_save = $json['refresh_token'] ?? null;
$token_esc = mysqli_real_escape_string($mysqli, $password);
$expires_at_esc = mysqli_real_escape_string($mysqli, $expires_at);
$refresh_sql = '';
if (!empty($refresh_token_to_save)) {
$refresh_token_esc = mysqli_real_escape_string($mysqli, $refresh_token_to_save);
$refresh_sql = ", config_mail_oauth_refresh_token = '{$refresh_token_esc}'";
}
mysqli_query($mysqli, "UPDATE settings SET config_mail_oauth_access_token = '{$token_esc}', config_mail_oauth_access_token_expires_at = '{$expires_at_esc}'{$refresh_sql} WHERE company_id = 1");
}
}
// Build remote socket (implicit SSL vs plain TCP) // Build remote socket (implicit SSL vs plain TCP)
$transport = 'tcp'; $transport = 'tcp';
if ($encryption === 'ssl') { if ($encryption === 'ssl') {
@ -178,19 +384,19 @@ if (isset($_POST['test_email_imap'])) {
$remote_socket = $transport . '://' . $host . ':' . $port; $remote_socket = $transport . '://' . $host . ':' . $port;
// Stream context (you can tighten these if you want strict validation) // Stream context (you can tighten these if you want strict validation)
$contextOptions = []; $context_options = [];
if (in_array($encryption, ['ssl', 'tls'], true)) { if (in_array($encryption, ['ssl', 'tls'], true)) {
$contextOptions['ssl'] = [ $context_options['ssl'] = [
'verify_peer' => false, 'verify_peer' => false,
'verify_peer_name' => false, 'verify_peer_name' => false,
'allow_self_signed' => true, 'allow_self_signed' => true,
]; ];
} }
$context = stream_context_create($contextOptions); $context = stream_context_create($context_options);
try { try {
$errno = 0; $errno = 0;
$errstr = ''; $errstr = '';
// 10-second timeout, adjust as needed // 10-second timeout, adjust as needed
@ -215,10 +421,8 @@ if (isset($_POST['test_email_imap'])) {
fclose($fp); fclose($fp);
throw new Exception("Invalid IMAP greeting: " . trim((string) $greeting)); throw new Exception("Invalid IMAP greeting: " . trim((string) $greeting));
} }
// If you really want STARTTLS for "tls" (port 143), you can do it here // If you really want STARTTLS for "tls" (port 143), you can do it here
if ($encryption === 'tls' && stripos($greeting, 'STARTTLS') !== false) { if ($encryption === 'tls' && stripos($greeting, 'STARTTLS') !== false) {
// Request STARTTLS
fwrite($fp, "A0001 STARTTLS\r\n"); fwrite($fp, "A0001 STARTTLS\r\n");
$line = fgets($fp, 1024); $line = fgets($fp, 1024);
if ($line === false || stripos($line, 'A0001 OK') !== 0) { if ($line === false || stripos($line, 'A0001 OK') !== 0) {
@ -226,28 +430,31 @@ if (isset($_POST['test_email_imap'])) {
throw new Exception("STARTTLS failed: " . trim((string) $line)); throw new Exception("STARTTLS failed: " . trim((string) $line));
} }
// Enable crypto on the stream
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
fclose($fp); fclose($fp);
throw new Exception("Unable to enable TLS encryption on IMAP connection."); throw new Exception("Unable to enable TLS encryption on IMAP connection.");
} }
} }
// --- Do LOGIN command ---
$tag = 'A0002'; $tag = 'A0002';
// Simple quoting; this may fail with some special chars in username/password. if ($is_oauth) {
$loginCmd = sprintf( $oauth_b64 = base64_encode("user={$username}\x01auth=Bearer {$password}\x01\x01");
"%s LOGIN \"%s\" \"%s\"\r\n", $auth_cmd = sprintf("%s AUTHENTICATE XOAUTH2 %s\r\n", $tag, $oauth_b64);
$tag, fwrite($fp, $auth_cmd);
addcslashes($username, "\\\""), } else {
addcslashes($password, "\\\"") $login_cmd = sprintf(
); "%s LOGIN \"%s\" \"%s\"\r\n",
$tag,
addcslashes($username, "\\\""),
addcslashes($password, "\\\"")
);
fwrite($fp, $loginCmd); fwrite($fp, $login_cmd);
}
$success = false; $success = false;
$errorLine = ''; $error_line = '';
while (!feof($fp)) { while (!feof($fp)) {
$line = fgets($fp, 2048); $line = fgets($fp, 2048);
@ -255,12 +462,11 @@ if (isset($_POST['test_email_imap'])) {
break; break;
} }
// Look for tagged response for our LOGIN
if (strpos($line, $tag . ' ') === 0) { if (strpos($line, $tag . ' ') === 0) {
if (stripos($line, $tag . ' OK') === 0) { if (stripos($line, $tag . ' OK') === 0) {
$success = true; $success = true;
} else { } else {
$errorLine = trim($line); $error_line = trim($line);
} }
break; break;
} }
@ -271,12 +477,16 @@ if (isset($_POST['test_email_imap'])) {
fclose($fp); fclose($fp);
if ($success) { if ($success) {
flash_alert("Connected successfully"); if ($is_oauth) {
} else { flash_alert("Connected successfully using OAuth");
if (!$errorLine) { } else {
$errorLine = 'Unknown IMAP authentication error'; flash_alert("Connected successfully");
} }
throw new Exception($errorLine); } else {
if (!$error_line) {
$error_line = 'Unknown IMAP authentication error';
}
throw new Exception($error_line);
} }
} catch (Exception $e) { } catch (Exception $e) {
@ -285,3 +495,88 @@ if (isset($_POST['test_email_imap'])) {
redirect(); redirect();
} }
if (isset($_POST['test_oauth_token_refresh'])) {
validateCSRFToken($_POST['csrf_token']);
$provider = sanitizeInput($_POST['oauth_provider'] ?? '');
if ($provider !== 'google_oauth' && $provider !== 'microsoft_oauth') {
flash_alert("OAuth token test failed: unsupported provider.", 'error');
redirect();
}
$oauth_client_id = sanitizeInput($config_mail_oauth_client_id ?? '');
$oauth_client_secret = sanitizeInput($config_mail_oauth_client_secret ?? '');
$oauth_tenant_id = sanitizeInput($config_mail_oauth_tenant_id ?? '');
$oauth_refresh_token = sanitizeInput($config_mail_oauth_refresh_token ?? '');
if (empty($oauth_client_id) || empty($oauth_client_secret) || empty($oauth_refresh_token)) {
flash_alert("OAuth token test failed: missing client ID, client secret, or refresh token.", 'error');
redirect();
}
if ($provider === 'microsoft_oauth' && empty($oauth_tenant_id)) {
flash_alert("OAuth token test failed: Microsoft tenant ID is required.", 'error');
redirect();
}
$token_url = 'https://oauth2.googleapis.com/token';
if ($provider === 'microsoft_oauth') {
$token_url = MICROSOFT_OAUTH_BASE_URL . rawurlencode($oauth_tenant_id) . "/oauth2/v2.0/token";
}
$post_fields = http_build_query([
'client_id' => $oauth_client_id,
'client_secret' => $oauth_client_secret,
'refresh_token' => $oauth_refresh_token,
'grant_type' => 'refresh_token',
]);
$ch = curl_init($token_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_fields);
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) {
$err_msg = !empty($curl_err) ? $curl_err : "HTTP $http_code";
flash_alert("OAuth token test failed: $err_msg", 'error');
redirect();
}
$json = json_decode($raw_body, true);
if (!is_array($json) || empty($json['access_token'])) {
flash_alert("OAuth token test failed: access token missing in provider response.", 'error');
redirect();
}
$new_access_token = sanitizeInput($json['access_token']);
$new_expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
$new_refresh_token = !empty($json['refresh_token']) ? sanitizeInput($json['refresh_token']) : '';
$new_access_token_esc = mysqli_real_escape_string($mysqli, $new_access_token);
$new_expires_at_esc = mysqli_real_escape_string($mysqli, $new_expires_at);
$refresh_sql = '';
if (!empty($new_refresh_token)) {
$new_refresh_token_esc = mysqli_real_escape_string($mysqli, $new_refresh_token);
$refresh_sql = ", config_mail_oauth_refresh_token = '$new_refresh_token_esc'";
}
mysqli_query($mysqli, "UPDATE settings SET config_mail_oauth_access_token = '$new_access_token_esc', config_mail_oauth_access_token_expires_at = '$new_expires_at_esc'$refresh_sql WHERE company_id = 1");
$provider_label = $provider === 'microsoft_oauth' ? 'Microsoft 365' : 'Google Workspace';
logAction("Settings", "Edit", "$session_name tested OAuth token refresh for $provider_label mail settings");
flash_alert("OAuth token refresh successful for $provider_label. Access token expires at $new_expires_at.");
redirect();
}

View File

@ -243,6 +243,32 @@ require_once "includes/inc_all_admin.php";
</div> </div>
</div> </div>
<?php
if (defined('BASE_URL') && !empty(BASE_URL)) {
$mail_oauth_callback_uri = rtrim((string) BASE_URL, '/') . '/admin/oauth_microsoft_mail_callback.php';
} else {
$mail_oauth_callback_uri = 'https://' . rtrim((string) $config_base_url, '/') . '/admin/oauth_microsoft_mail_callback.php';
}
?>
<div class="form-group">
<label>Microsoft OAuth Connect (Web)</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-link"></i></span>
</div>
<input type="text" class="form-control" readonly value="<?php echo htmlspecialchars($mail_oauth_callback_uri); ?>">
<div class="input-group-append">
<button type="submit" name="oauth_connect_microsoft_mail" class="btn btn-outline-primary">
<i class="fas fa-fw fa-sign-in-alt mr-2"></i>Connect Microsoft 365
</button>
</div>
</div>
<small class="text-secondary">
Add this callback URI in Entra App Registration, then click Connect to authorize and store refresh token automatically.
</small>
</div>
<hr> <hr>
<button type="submit" name="edit_mail_imap_settings" class="btn btn-primary text-bold"><i class="fas fa-check mr-2"></i>Save</button> <button type="submit" name="edit_mail_imap_settings" class="btn btn-primary text-bold"><i class="fas fa-check mr-2"></i>Save</button>
@ -359,7 +385,22 @@ require_once "includes/inc_all_admin.php";
</div> </div>
</div> </div>
<?php if (!empty($config_smtp_host) && !empty($config_smtp_port) && !empty($config_mail_from_email) && !empty($config_mail_from_name)) { ?> <?php
$smtp_standard_ready = !empty($config_smtp_host)
&& !empty($config_smtp_port)
&& !empty($config_mail_from_email)
&& !empty($config_mail_from_name);
$smtp_oauth_ready = ($config_smtp_provider === 'google_oauth' || $config_smtp_provider === 'microsoft_oauth')
&& !empty($config_mail_from_email)
&& !empty($config_mail_from_name)
&& !empty($config_mail_oauth_client_id)
&& !empty($config_mail_oauth_client_secret)
&& !empty($config_mail_oauth_refresh_token)
&& ($config_smtp_provider !== 'microsoft_oauth' || !empty($config_mail_oauth_tenant_id));
?>
<?php if ($smtp_standard_ready || $smtp_oauth_ready) { ?>
<div class="card card-dark"> <div class="card card-dark">
<div class="card-header py-3"> <div class="card-header py-3">
@ -409,7 +450,21 @@ require_once "includes/inc_all_admin.php";
<?php } ?> <?php } ?>
<?php if (!empty($config_imap_username) && !empty($config_imap_password) && !empty($config_imap_host) && !empty($config_imap_port)) { ?> <?php
$imap_standard_ready = !empty($config_imap_username)
&& !empty($config_imap_password)
&& !empty($config_imap_host)
&& !empty($config_imap_port);
$imap_oauth_ready = ($config_imap_provider === 'google_oauth' || $config_imap_provider === 'microsoft_oauth')
&& !empty($config_imap_username)
&& !empty($config_mail_oauth_client_id)
&& !empty($config_mail_oauth_client_secret)
&& !empty($config_mail_oauth_refresh_token)
&& ($config_imap_provider !== 'microsoft_oauth' || !empty($config_mail_oauth_tenant_id));
?>
<?php if ($imap_standard_ready || $imap_oauth_ready) { ?>
<div class="card card-dark"> <div class="card card-dark">
<div class="card-header py-3"> <div class="card-header py-3">
@ -428,6 +483,46 @@ require_once "includes/inc_all_admin.php";
<?php } ?> <?php } ?>
<?php
$oauth_provider_for_test = '';
if ($config_imap_provider === 'google_oauth' || $config_imap_provider === 'microsoft_oauth') {
$oauth_provider_for_test = $config_imap_provider;
} elseif ($config_smtp_provider === 'google_oauth' || $config_smtp_provider === 'microsoft_oauth') {
$oauth_provider_for_test = $config_smtp_provider;
}
$oauth_has_required_fields = !empty($oauth_provider_for_test)
&& !empty($config_mail_oauth_client_id)
&& !empty($config_mail_oauth_client_secret)
&& !empty($config_mail_oauth_refresh_token)
&& ($oauth_provider_for_test !== 'microsoft_oauth' || !empty($config_mail_oauth_tenant_id));
?>
<?php if ($oauth_has_required_fields) { ?>
<div class="card card-dark">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-key mr-2"></i>Test OAuth Token Refresh</h3>
</div>
<div class="card-body">
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<input type="hidden" name="oauth_provider" value="<?php echo htmlspecialchars($oauth_provider_for_test); ?>">
<p class="text-secondary mb-3">
This validates your refresh token and stores a new access token for
<?php echo $oauth_provider_for_test === 'microsoft_oauth' ? 'Microsoft 365' : 'Google Workspace'; ?>.
</p>
<button type="submit" name="test_oauth_token_refresh" class="btn btn-success">
<i class="fas fa-fw fa-sync-alt mr-2"></i>Test OAuth Token Refresh
</button>
</form>
</div>
</div>
<?php } ?>
<script> <script>
(function(){ (function(){
function setDisabled(container, disabled){ function setDisabled(container, disabled){

View File

@ -107,7 +107,7 @@ if (isset($_POST['add_ticket'])) {
} }
// E-mail client // E-mail client
if (!empty($config_smtp_host) && $config_ticket_client_general_notifications == 1) { if ((!empty($config_smtp_host) || !empty($config_smtp_provider)) && $config_ticket_client_general_notifications == 1) {
// Get contact/ticket details // Get contact/ticket details
$sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, ticket_prefix, ticket_number, ticket_category, ticket_subject, ticket_details, ticket_priority, ticket_status, ticket_created_by, ticket_assigned_to, ticket_client_id FROM tickets $sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, ticket_prefix, ticket_number, ticket_category, ticket_subject, ticket_details, ticket_priority, ticket_status, ticket_created_by, ticket_assigned_to, ticket_client_id FROM tickets
@ -260,7 +260,7 @@ if (isset($_POST['edit_ticket'])) {
$client_id = intval($row['ticket_client_id']); $client_id = intval($row['ticket_client_id']);
// Notify new contact if selected // Notify new contact if selected
if ($notify && !empty($config_smtp_host)) { if ($notify && (!empty($config_smtp_host) || !empty($config_smtp_provider))) {
// Get Company Name Phone Number and Sanitize for Email Sending // Get Company Name Phone Number and Sanitize for Email Sending
$sql = mysqli_query($mysqli, "SELECT company_name, company_phone, company_phone_country_code FROM companies WHERE company_id = 1"); $sql = mysqli_query($mysqli, "SELECT company_name, company_phone, company_phone_country_code FROM companies WHERE company_id = 1");
@ -379,7 +379,7 @@ if (isset($_POST['edit_ticket_contact'])) {
$contact_email = sanitizeInput($row['contact_email']); $contact_email = sanitizeInput($row['contact_email']);
// Notify new contact (if selected, valid & configured) // Notify new contact (if selected, valid & configured)
if ($notify && filter_var($contact_email, FILTER_VALIDATE_EMAIL) && !empty($config_smtp_host)) { if ($notify && filter_var($contact_email, FILTER_VALIDATE_EMAIL) && (!empty($config_smtp_host) || !empty($config_smtp_provider))) {
// Get Company Phone Number // Get Company Phone Number
$sql = mysqli_query($mysqli, "SELECT company_name, company_phone, company_phone_country_code FROM companies WHERE company_id = 1"); $sql = mysqli_query($mysqli, "SELECT company_name, company_phone, company_phone_country_code FROM companies WHERE company_id = 1");
@ -489,7 +489,7 @@ if (isset($_POST['add_ticket_watcher'])) {
mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$watcher_email', watcher_ticket_id = $ticket_id"); mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$watcher_email', watcher_ticket_id = $ticket_id");
// Notify watcher // Notify watcher
if ($notify && !empty($config_smtp_host)) { if ($notify && (!empty($config_smtp_host) || !empty($config_smtp_provider))) {
@ -734,7 +734,7 @@ if (isset($_POST['assign_ticket'])) {
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Ticket', notification = 'Ticket $ticket_prefix$ticket_number - Subject: $ticket_subject has been assigned to you by $session_name', notification_action = '/agent/ticket.php?ticket_id=$ticket_id$client_uri', notification_client_id = $client_id, notification_user_id = $assigned_to"); mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Ticket', notification = 'Ticket $ticket_prefix$ticket_number - Subject: $ticket_subject has been assigned to you by $session_name', notification_action = '/agent/ticket.php?ticket_id=$ticket_id$client_uri', notification_client_id = $client_id, notification_user_id = $assigned_to");
// Email Notification // Email Notification
if (!empty($config_smtp_host)) { if (!empty($config_smtp_host) || !empty($config_smtp_provider)) {
// Sanitize Config vars from get_settings.php // Sanitize Config vars from get_settings.php
$config_ticket_from_name = sanitizeInput($config_ticket_from_name); $config_ticket_from_name = sanitizeInput($config_ticket_from_name);
@ -926,7 +926,7 @@ if (isset($_POST['bulk_assign_ticket'])) {
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Ticket', notification = '$ticket_count Tickets have been assigned to you by $session_name', notification_action = 'tickets.php?status=Open&assigned=$assign_to', notification_client_id = $client_id, notification_user_id = $assign_to"); mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Ticket', notification = '$ticket_count Tickets have been assigned to you by $session_name', notification_action = 'tickets.php?status=Open&assigned=$assign_to', notification_client_id = $client_id, notification_user_id = $assign_to");
// Agent Email Notification // Agent Email Notification
if (!empty($config_smtp_host)) { if (!empty($config_smtp_host) || !empty($config_smtp_provider)) {
// Sanitize Config vars from get_settings.php // Sanitize Config vars from get_settings.php
$config_ticket_from_name = sanitizeInput($config_ticket_from_name); $config_ticket_from_name = sanitizeInput($config_ticket_from_name);
@ -1173,7 +1173,7 @@ if (isset($_POST['bulk_resolve_tickets'])) {
customAction('ticket_resolve', $ticket_id); customAction('ticket_resolve', $ticket_id);
// Client notification email // Client notification email
if (!empty($config_smtp_host) && $config_ticket_client_general_notifications == 1 && $private_note == 0) { if ((!empty($config_smtp_host) || !empty($config_smtp_provider)) && $config_ticket_client_general_notifications == 1 && $private_note == 0) {
// Get Contact details // Get Contact details
$ticket_sql = mysqli_query($mysqli, "SELECT contact_name, contact_email FROM tickets $ticket_sql = mysqli_query($mysqli, "SELECT contact_name, contact_email FROM tickets
@ -1352,7 +1352,7 @@ if (isset($_POST['bulk_ticket_reply'])) {
$company_phone = sanitizeInput(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code'])); $company_phone = sanitizeInput(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code']));
// Send e-mail to client if public update & email is set up // Send e-mail to client if public update & email is set up
if ($private_note == 0 && !empty($config_smtp_host)) { if ($private_note == 0 && (!empty($config_smtp_host) || !empty($config_smtp_provider))) {
$subject = "Ticket update - [$ticket_prefix$ticket_number] - $ticket_subject"; $subject = "Ticket update - [$ticket_prefix$ticket_number] - $ticket_subject";
$body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>Your ticket regarding $ticket_subject has been updated.<br><br>--------------------------------<br>$ticket_reply<br>--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: $ticket_status_name<br>Portal: <a href=\'https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=$url_key\'>View ticket</a><br><br>--<br>$company_name - Support<br>$from_email<br>$company_phone"; $body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>Your ticket regarding $ticket_subject has been updated.<br><br>--------------------------------<br>$ticket_reply<br>--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: $ticket_status_name<br>Portal: <a href=\'https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=$url_key\'>View ticket</a><br><br>--<br>$company_name - Support<br>$from_email<br>$company_phone";
@ -1669,7 +1669,7 @@ if (isset($_POST['add_ticket_reply'])) {
$company_phone = sanitizeInput(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code'])); $company_phone = sanitizeInput(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code']));
// Send e-mail to client if public update & email is set up // Send e-mail to client if public update & email is set up
if ($ticket_reply_type == 'Public' && $send_email == 1 && !empty($config_smtp_host)) { if ($ticket_reply_type == 'Public' && $send_email == 1 && (!empty($config_smtp_host) || !empty($config_smtp_provider))) {
// Slightly different email subject/text depending on if this update set auto-close // Slightly different email subject/text depending on if this update set auto-close
@ -1935,7 +1935,7 @@ if (isset($_GET['resolve_ticket'])) {
customAction('ticket_resolve', $ticket_id); customAction('ticket_resolve', $ticket_id);
// Client notification email // Client notification email
if (!empty($config_smtp_host) && $config_ticket_client_general_notifications == 1) { if ((!empty($config_smtp_host) || !empty($config_smtp_provider)) && $config_ticket_client_general_notifications == 1) {
// Get details // Get details
$ticket_sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, ticket_prefix, ticket_number, ticket_subject, ticket_status_name, ticket_assigned_to, ticket_url_key, ticket_client_id FROM tickets $ticket_sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, ticket_prefix, ticket_number, ticket_subject, ticket_status_name, ticket_assigned_to, ticket_url_key, ticket_client_id FROM tickets
@ -2032,7 +2032,7 @@ if (isset($_GET['close_ticket'])) {
customAction('ticket_close', $ticket_id); customAction('ticket_close', $ticket_id);
// Client notification email // Client notification email
if (!empty($config_smtp_host) && $config_ticket_client_general_notifications == 1) { if ((!empty($config_smtp_host) || !empty($config_smtp_provider)) && $config_ticket_client_general_notifications == 1) {
// Get details // Get details
$ticket_sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, ticket_prefix, ticket_number, ticket_subject, ticket_url_key FROM tickets $ticket_sql = mysqli_query($mysqli, "SELECT contact_name, contact_email, ticket_prefix, ticket_number, ticket_subject, ticket_url_key FROM tickets

View File

@ -23,6 +23,14 @@ use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception; use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\OAuthTokenProvider; 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) * XOAUTH2 Token Provider for PHPMailer (simple “static” provider)
* ======================================================================= */ * ======================================================================= */
@ -93,10 +101,113 @@ if (file_exists($lock_file_path)) {
file_put_contents($lock_file_path, "Locked"); file_put_contents($lock_file_path, "Locked");
/** ======================================================================= /** =======================================================================
* Mail sender function (defined inside this cron) * Mail OAuth helpers + sender function
* - Handles standard SMTP and XOAUTH2 for Google/Microsoft
* - Reuses shared OAuth settings
* ======================================================================= */ * ======================================================================= */
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( function sendQueueEmail(
string $provider, string $provider,
string $host, string $host,
@ -153,27 +264,21 @@ function sendQueueEmail(
$mail->AuthType = 'XOAUTH2'; $mail->AuthType = 'XOAUTH2';
$mail->Username = $username; $mail->Username = $username;
// Pick/refresh access token $access_token = resolveMailOauthAccessToken(
$accessToken = trim($oauth_access_token); $provider,
$needsRefresh = empty($accessToken); trim($oauth_client_id),
if (!$needsRefresh && !empty($oauth_access_token_expires_at)) { trim($oauth_client_secret),
$expTs = strtotime($oauth_access_token_expires_at); trim($oauth_tenant_id),
if ($expTs && $expTs <= time() + 60) $needsRefresh = true; trim($oauth_refresh_token),
} trim($oauth_access_token),
trim($oauth_access_token_expires_at)
);
if ($needsRefresh) { if (empty($access_token)) {
if ($provider === 'google_oauth' && function_exists('getGoogleAccessToken')) {
$accessToken = getGoogleAccessToken($username);
} elseif ($provider === 'microsoft_oauth' && function_exists('getMicrosoftAccessToken')) {
$accessToken = getMicrosoftAccessToken($username);
}
}
if (empty($accessToken)) {
throw new Exception("Missing OAuth access token for XOAUTH2 SMTP."); throw new Exception("Missing OAuth access token for XOAUTH2 SMTP.");
} }
$mail->setOAuth(new StaticTokenProvider($username, $accessToken)); $mail->setOAuth(new StaticTokenProvider($username, $access_token));
} else { } else {
// Standard SMTP (with or without auth) // Standard SMTP (with or without auth)
$mail->SMTPAuth = !empty($username); $mail->SMTPAuth = !empty($username);

View File

@ -862,7 +862,7 @@ foreach ($messages as $message) {
// >>> Put the extra logging RIGHT HERE // >>> Put the extra logging RIGHT HERE
$subj = (string)$message->getSubject(); $subj = (string)$message->getSubject();
$uid = method_exists($message, 'getUid') ? $message->getUid() : 'n/a'; $uid = method_exists($message, 'getUid') ? $message->getUid() : 'n/a';
$path = property_exists($targetFolder, 'path') ? $targetFolder->path : 'ITFlow'; $path = (is_object($targetFolder) && property_exists($targetFolder, 'path')) ? (string)$targetFolder->path : $targetFolderPath;
logApp( logApp(
"Cron-Email-Parser", "Cron-Email-Parser",
"warning", "warning",