Files
itflow/admin/post/settings_mail.php
johnnyq 9457442804 Emails stage 3: every email now goes through emailQueue and the shared layout - the client portal invite sends a link to set a password (or to sign in with Microsoft) instead of the password itself, offered on New Contact as well as Edit through portalMailInvite, with Forgot password, password changed and two-step sign-in emails through portalMailPasswordReset and portalMailAccountChange; shared link and code emails with the note quoted and the whole link printed under the button, replacing shareMailHtml and shareQueueMail; calendar invitations, updates and cancellations with when, repeats, where, the description and a join button; Email on the contacts and clients lists in the layout from one of the configured mailboxes with an optional greeting and send time, leaving out archived contacts and contacts without an address, each address once, nothing queued without mail set up, through emailBulkSend and emailBulkPost with a shared bulk_email_fields window; Admin > Mail > Previews shows all 52 emails built by their real senders on sample records inside a rolled-back transaction with emailQueue capturing and custom actions off, with computer and phone widths and Send me a copy; emailHtml gains content and button show_url, emailQueue gains queued_at and always so billing still queues as before, plus emailMailboxes and emailTestSend; ticketMailCreated, ticketMailReply, ticketMailWatchersAdded, ticketMailTaskApproval, ticketMailClosedReply, ticketSlaAlert and billingMailCardSaved moved into functions; fixes Email on the contacts list queuing mail to contacts with no address, the clients list including archived contacts, and both taking any From address posted; changelog updated
2026-10-17 23:26:25 -04:00

519 lines
23 KiB
PHP

<?php
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
// Saves and tests return to the tab they were submitted from (see settings_mail.php)
$mail_tabs = ['sending', 'receiving', 'oauth', 'from', 'tests', 'previews'];
$mail_tab_redirect = 'settings_mail.php';
if (isset($_POST['tab']) && in_array($_POST['tab'], $mail_tabs, true)) {
$mail_tab_redirect .= '?tab=' . $_POST['tab'];
}
if (!defined('MICROSOFT_OAUTH_BASE_URL')) {
define('MICROSOFT_OAUTH_BASE_URL', 'https://login.microsoftonline.com/');
}
if (isset($_POST['oauth_connect_microsoft_mail'])) {
validateCSRFToken();
// Save the OAuth credential fields from this form so the auth flow uses the latest inputs
$config_mail_oauth_client_id = escapeSql($_POST['config_mail_oauth_client_id'] ?? '');
$config_mail_oauth_client_secret = escapeSql($_POST['config_mail_oauth_client_secret'] ?? '');
$config_mail_oauth_tenant_id = escapeSql($_POST['config_mail_oauth_tenant_id'] ?? $config_mail_oauth_tenant_id);
$config_mail_oauth_refresh_token = escapeSql($_POST['config_mail_oauth_refresh_token'] ?? '');
$config_mail_oauth_access_token = escapeSql($_POST['config_mail_oauth_access_token'] ?? '');
mysqli_query($mysqli, "UPDATE settings SET
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
");
// Check the SAVED providers (loaded from config at bootstrap), not $_POST —
// the provider dropdowns live in different forms and are never posted here
if ($config_imap_provider !== 'microsoft_oauth' && $config_smtp_provider !== 'microsoft_oauth') {
flashAlert("Please set the SMTP or IMAP Provider to Microsoft 365 (OAuth) and save it before connecting.", 'error');
redirect($mail_tab_redirect);
}
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_tenant_id)) {
flashAlert("Missing Microsoft OAuth settings. Please provide Client ID, Client Secret, and Tenant ID first.", 'error');
redirect($mail_tab_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);
logAudit("Settings", "Edit", "$session_name started Microsoft OAuth connect flow for mail settings");
redirect($authorize_url);
}
if (isset($_POST['edit_mail_smtp_settings'])) {
validateCSRFToken();
$config_smtp_provider = escapeSql($_POST['config_smtp_provider']);
$config_smtp_host = escapeSql($_POST['config_smtp_host'] ?? $config_smtp_host);
$config_smtp_port = intval($_POST['config_smtp_port'] ?? $config_smtp_port);
$config_smtp_encryption = escapeSql($_POST['config_smtp_encryption'] ?? $config_smtp_encryption);
$config_smtp_username = escapeSql($_POST['config_smtp_username'] ?? $config_smtp_username);
$config_smtp_password = escapeSql($_POST['config_smtp_password'] ?? $config_smtp_password);
// The host/port/encryption/password inputs are hidden and disabled for OAuth
// providers, so they never post and the ?? fallbacks above keep whatever the
// install used before. Clear them instead: the endpoint is fixed by provider,
// and the stored values would otherwise be both wrong and unreachable from
// the settings page. The mailbox password is no longer used either.
if ($config_smtp_provider === 'google_oauth' || $config_smtp_provider === 'microsoft_oauth') {
$config_smtp_host = '';
$config_smtp_port = 0;
$config_smtp_encryption = '';
$config_smtp_password = '';
}
mysqli_query($mysqli, "
UPDATE settings SET
config_smtp_provider = '$config_smtp_provider',
config_smtp_host = '$config_smtp_host',
config_smtp_port = $config_smtp_port,
config_smtp_encryption = '$config_smtp_encryption',
config_smtp_username = '$config_smtp_username',
config_smtp_password = '$config_smtp_password'
WHERE company_id = 1
");
logAudit("Settings", "Edit", "$session_name edited SMTP settings");
flashAlert("SMTP Mail Settings updated");
redirect($mail_tab_redirect);
}
if (isset($_POST['edit_mail_imap_settings'])) {
validateCSRFToken();
$config_imap_provider = escapeSql($_POST['config_imap_provider']);
$config_imap_host = escapeSql($_POST['config_imap_host'] ?? $config_imap_host);
$config_imap_port = intval($_POST['config_imap_port'] ?? $config_imap_port);
$config_imap_encryption = escapeSql($_POST['config_imap_encryption'] ?? $config_imap_encryption);
$config_imap_username = escapeSql($_POST['config_imap_username'] ?? $config_imap_username);
$config_imap_password = escapeSql($_POST['config_imap_password'] ?? $config_imap_password);
// Same as the SMTP handler above - the connection fields are hidden for OAuth
// providers and never post, so drop the leftovers rather than carrying them.
if ($config_imap_provider === 'google_oauth' || $config_imap_provider === 'microsoft_oauth') {
$config_imap_host = '';
$config_imap_port = 0;
$config_imap_encryption = '';
$config_imap_password = '';
}
mysqli_query($mysqli, "
UPDATE settings SET
config_imap_provider = '$config_imap_provider',
config_imap_host = '$config_imap_host',
config_imap_port = $config_imap_port,
config_imap_encryption = '$config_imap_encryption',
config_imap_username = '$config_imap_username',
config_imap_password = '$config_imap_password'
WHERE company_id = 1
");
logAudit("Settings", "Edit", "$session_name edited IMAP settings");
flashAlert("IMAP Mail Settings updated");
redirect($mail_tab_redirect);
}
if (isset($_POST['edit_mail_oauth_settings'])) {
validateCSRFToken();
$config_mail_oauth_client_id = escapeSql($_POST['config_mail_oauth_client_id'] ?? '');
$config_mail_oauth_client_secret = escapeSql($_POST['config_mail_oauth_client_secret'] ?? '');
$config_mail_oauth_tenant_id = escapeSql($_POST['config_mail_oauth_tenant_id'] ?? $config_mail_oauth_tenant_id);
$config_mail_oauth_refresh_token = escapeSql($_POST['config_mail_oauth_refresh_token'] ?? '');
$config_mail_oauth_access_token = escapeSql($_POST['config_mail_oauth_access_token'] ?? '');
mysqli_query($mysqli, "UPDATE settings SET
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
");
logAudit("Settings", "Edit", "$session_name edited mail OAuth settings");
flashAlert("Mail OAuth Settings updated");
redirect($mail_tab_redirect);
}
if (isset($_POST['edit_mail_from_settings'])) {
validateCSRFToken();
$config_mail_from_email = escapeSql(filter_var($_POST['config_mail_from_email'], FILTER_VALIDATE_EMAIL));
$config_mail_from_name = escapeSql(preg_replace('/[^a-zA-Z0-9\s]/', '', $_POST['config_mail_from_name']));
$config_invoice_from_email = escapeSql(filter_var($_POST['config_invoice_from_email'], FILTER_VALIDATE_EMAIL));
$config_invoice_from_name = escapeSql(preg_replace('/[^a-zA-Z0-9\s]/', '', $_POST['config_invoice_from_name']));
$config_quote_from_email = escapeSql(filter_var($_POST['config_quote_from_email'], FILTER_VALIDATE_EMAIL));
$config_quote_from_name = escapeSql(preg_replace('/[^a-zA-Z0-9\s]/', '', $_POST['config_quote_from_name']));
$config_ticket_from_email = escapeSql(filter_var($_POST['config_ticket_from_email'], FILTER_VALIDATE_EMAIL));
$config_ticket_from_name = escapeSql(preg_replace('/[^a-zA-Z0-9\s]/', '', $_POST['config_ticket_from_name']));
mysqli_query($mysqli,"UPDATE settings SET config_mail_from_email = '$config_mail_from_email', config_mail_from_name = '$config_mail_from_name', config_invoice_from_email = '$config_invoice_from_email', config_invoice_from_name = '$config_invoice_from_name', config_quote_from_email = '$config_quote_from_email', config_quote_from_name = '$config_quote_from_name', config_ticket_from_email = '$config_ticket_from_email', config_ticket_from_name = '$config_ticket_from_name' WHERE company_id = 1");
logAudit("Settings", "Edit", "$session_name edited mail from settings");
flashAlert("Mail From Settings updated");
redirect($mail_tab_redirect);
}
// A copy of one of the previews (Admin > Mail > Previews), to the admin asking
if (isset($_POST['send_email_preview'])) {
validateCSRFToken();
$key = (string) ($_POST['email'] ?? '');
$catalog = emailPreviewCatalog();
if (!isset($catalog[$key])) {
flashAlert('There is no such email', 'error');
redirect('settings_mail.php?tab=previews');
}
if (!emailReady()) {
flashAlert('Set up how mail is sent first, then send yourself a copy', 'error');
redirect('settings_mail.php?tab=previews&email=' . rawurlencode($key));
}
if (emailPreviewSendCopy($key, $session_email, stripslashes($session_name))) {
logAudit("Settings", "Edit", "$session_name sent themselves a preview of the " . escapeSql($catalog[$key]['group'] . ' - ' . $catalog[$key]['label']) . " email");
flashAlert('A copy of <strong>' . escapeHtml($catalog[$key]['label']) . '</strong> is queued to ' . escapeHtml($session_email));
} else {
flashAlert('The copy could not be sent - check your own email address in Your account', 'error');
}
redirect('settings_mail.php?tab=previews&email=' . rawurlencode($key));
}
if (isset($_POST['test_email_smtp'])) {
validateCSRFToken();
$test_email = intval($_POST['test_email']);
// From the mailbox picked - the system's, invoices', quotes' or tickets' (emailTestSend(), functions/email.php)
$mailbox = [1 => 'system', 2 => 'invoice', 3 => 'quote'][$test_email] ?? 'ticket';
$to = trim((string) ($_POST['email_to'] ?? ''));
if (!emailReady()) {
flashAlert('Set up how mail is sent first, then send the test', 'error');
redirect($mail_tab_redirect);
}
$sent = emailTestSend($to, $mailbox);
if ($sent) {
flashAlert("Test email queued! Check <strong>Admin &gt; Mail queue</strong>");
} else {
flashAlert(escapeHtml($to) . " isn't an email address - nothing was queued", 'error');
}
redirect($mail_tab_redirect);
}
if (isset($_POST['test_email_imap'])) {
validateCSRFToken();
$provider = escapeSql($config_imap_provider ?? '');
$host = $config_imap_host;
$port = (int) $config_imap_port;
$encryption = strtolower(trim($config_imap_encryption)); // e.g. "ssl", "tls", "none"
$username = $config_imap_username;
$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');
// Override, don't default - a leftover standard-IMAP host from before the
// switch to OAuth would otherwise make this test fail against the old server
// while the cron parser (which overrides unconditionally) connects fine.
if ($provider === 'google_oauth') {
$host = 'imap.gmail.com';
$port = 993;
$encryption = 'ssl';
} elseif ($provider === 'microsoft_oauth') {
$host = 'outlook.office365.com';
$port = 993;
$encryption = 'ssl';
}
if (empty($host) || empty($port) || empty($username)) {
flashAlert("<strong>IMAP connection failed:</strong> Missing host, port, or username.", 'error');
redirect($mail_tab_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)) {
flashAlert("<strong>IMAP OAuth failed:</strong> Missing OAuth client credentials or refresh token.", 'error');
redirect($mail_tab_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)) {
flashAlert("<strong>IMAP OAuth failed:</strong> Microsoft tenant ID is required.", 'error');
redirect($mail_tab_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']) {
flashAlert("<strong>IMAP OAuth failed:</strong> Could not refresh access token.", 'error');
redirect($mail_tab_redirect);
}
$json = json_decode($response['body'], true);
if (!is_array($json) || empty($json['access_token'])) {
flashAlert("<strong>IMAP OAuth failed:</strong> Token response did not include an access token.", 'error');
redirect($mail_tab_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)
require_once $_SERVER['DOCUMENT_ROOT'] . '/libs/vendor/autoload.php'; // ImapEngine (composer)
// Map the stored encryption value to an ImapEngine transport (matches the cron sync)
$imap_transport = match ($encryption) {
'ssl' => 'ssl', // implicit TLS (993)
'tls' => 'starttls', // STARTTLS upgrade (143) - Webklex semantics
'starttls' => 'starttls',
default => '', // 'none' / plain TCP
};
try {
// Same ImapEngine client the cron sync uses, so a passing test predicts a
// working sync. Typed errors instead of raw banners; host validated at save.
$mailbox = new \DirectoryTree\ImapEngine\Mailbox([
'host' => $host,
'port' => $port,
'encryption' => $imap_transport,
'validate_cert' => true,
'username' => $username,
'password' => $password, // access token when OAuth
'authentication' => $is_oauth ? 'oauth' : 'plain',
]);
$mailbox->connect();
$mailbox->inbox(); // confirm auth + mailbox access, like the sync does
flashAlert($is_oauth ? "Connected successfully using OAuth" : "Connected successfully");
} catch (\Throwable $e) {
flashAlert("<strong>IMAP connection failed.</strong> Check the host, port, encryption, and credentials.", 'error');
}
redirect($mail_tab_redirect);
}
if (isset($_POST['test_oauth_token_refresh'])) {
validateCSRFToken();
$provider = escapeSql($_POST['oauth_provider'] ?? '');
if ($provider !== 'google_oauth' && $provider !== 'microsoft_oauth') {
flashAlert("OAuth token test failed: unsupported provider.", 'error');
redirect($mail_tab_redirect);
}
$oauth_client_id = escapeSql($config_mail_oauth_client_id ?? '');
$oauth_client_secret = escapeSql($config_mail_oauth_client_secret ?? '');
$oauth_tenant_id = escapeSql($config_mail_oauth_tenant_id ?? '');
$oauth_refresh_token = escapeSql($config_mail_oauth_refresh_token ?? '');
if (empty($oauth_client_id) || empty($oauth_client_secret) || empty($oauth_refresh_token)) {
flashAlert("OAuth token test failed: missing client ID, client secret, or refresh token.", 'error');
redirect($mail_tab_redirect);
}
if ($provider === 'microsoft_oauth' && empty($oauth_tenant_id)) {
flashAlert("OAuth token test failed: Microsoft tenant ID is required.", 'error');
redirect($mail_tab_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";
flashAlert("OAuth token test failed: $err_msg", 'error');
redirect($mail_tab_redirect);
}
$json = json_decode($raw_body, true);
if (!is_array($json) || empty($json['access_token'])) {
flashAlert("OAuth token test failed: access token missing in provider response.", 'error');
redirect($mail_tab_redirect);
}
$new_access_token = escapeSql($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']) ? escapeSql($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';
logAudit("Settings", "Edit", "$session_name tested OAuth token refresh for $provider_label mail settings");
flashAlert("OAuth token refresh successful for $provider_label. Access token expires at $new_expires_at.");
redirect($mail_tab_redirect);
}