diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7eeca978d..29d993be9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -161,6 +161,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run
Click the value and pick a new one, or use the + at the end of a record's tags. Each change saves straight away.
- Tickets: a closed ticket can no longer be changed, either from the ticket's windows or through the API. Merging is only offered between tickets of the same client.
- Tickets: a ticket that closes by itself some time after it was resolved now sends the client the closed email, as closing it yourself does, when client notifications are on (Admin > Ticket). It used to close without a word.
+- Tickets: an agent is now emailed when a ticket is assigned to them from New Ticket or Edit, as they already were from the assignee pick and Bulk Action. It used to be an app notice only. Assigning a ticket to yourself sends nothing.
+- Emails to agents keep their subjects, so mail rules still match. The ones that changed: a client's reply from the portal is "ITFlow - Ticket updated - [TCK-12] Subject", with the dash the emailed reply always had; one recurring ticket assigned from Bulk Action is "ITFlow - Recurring ticket Subject assigned to you", as from the assignee pick; and a ticket raised on a schedule reaches the new-ticket address as "ITFlow - New Recurring Ticket - Client: Subject".
- Client portal: primary and technical contacts' Tickets page now opens on everyone's tickets at their company, with Mine to see only their own. The separate All tickets page is gone, and links to it land on Tickets. A ticket shows the client where it stands - Received, In progress, Waiting on you, On hold, Resolved or Closed - rather than your ticket statuses.
- Client portal: invoices and quotes now open in the portal, where they used to open their guest link in a new tab. The links in your emails work as before. A quote answered in the portal records the contact's name and any note they left. Pay invoices pays the invoices ticked as one card payment, and the Pay drop-down on each invoice is gone (see Client portal).
- Client portal: only the primary contact can add or change contacts now, and give or turn off their portal access. Technical contacts still see the contacts, but can no longer add or edit them. A primary contact can turn access back on for someone you turned off, and it is logged and notified when they do.
@@ -846,6 +848,16 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- Resolved says when it closes by itself, with a button to view or reopen it. Closed asks how you did.
- The subjects are the same and the reply line stays at the top, so replies still go onto their ticket and are cut off where they were.
- Images in a reply point at your ITFlow, so they show, and anything in a reply that could run in the email is taken out.
+ - Scheduled work: the contact, the ticket's watchers and the assigned agent get the date, time and whether it is on site, with the calendar file as before. Cancelling it tells them the same way, and the calendar file takes it out of their calendar.
+ - Emails to your team are in the same look, each with an Open in ITFlow button:
+ - Assigned to you: who assigned it, with the details and the client. Several from Bulk Action come as one email listing them, with a button to the agent's tickets. Recurring tickets the same way.
+ - New ticket, to the new-ticket address (Admin > Ticket): who raised it and how - in the portal, by email or on a schedule - with what they wrote. A ticket raised on a schedule now reaches it too.
+ - Client replied, to the agent the ticket is assigned to: who replied, in the portal or by email, with what they wrote. A portal reply used to say only to check ITFlow.
+ - SLA at risk and missed, to the SLA alert address (Admin > SLAs) and the assigned agent: which target, when it is or was due and who has it, in amber for at risk and red once missed. An agent who is also the alert address gets one.
+ - Shared links: a login sent back through a link you made, with a button to review it.
+ - Account emails: a new agent's sign in details with a Sign in button (and that the email has their password in it), a change to their account, a sign in from a new device and a wrong MFA code. The sign in alerts say when, the browser and system in words, and the IP address, with a button to their sign in security.
+ - The test email in Admin > Mail comes in the new look, saying which mailbox it was sent from.
+ - Client portal: the email confirming a saved card is in the billing emails' look, from the invoice mailbox, naming the card and when it expires.
### Bug Fixes
@@ -951,6 +963,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- Roles: the built-in Accountant role couldn't take a payment, email a reminder or statement, or delete an expense or other income entered by mistake.
- Tickets: a ticket raised from a recurring ticket never emailed its contact or the new-ticket address, as the settings it read were never loaded.
- Tickets: the email telling someone their email had become a ticket showed an apostrophe in its subject, or in their name, with a backslash in front of it.
+- Tickets: SLA alert emails were queued when outgoing mail was not set up. For a ticket raised or answered by email, the new ticket and client replied emails showed an apostrophe in the subject, the client's name or the reply with a backslash in front of it.
+- Admin > Mail: the test email was queued when outgoing mail was not set up, or when what was typed was not an email address, and greeted everyone as "Chap".
+- Emails: a saved card's confirmation, a new agent's sign in details and the email about a change to an agent's account were only sent when an SMTP host was filled in, so they were skipped when mail goes through Microsoft 365 or Google with no host. The saved card's email began "Were writing".
- Contacts: an agent without Support saw a contact's Assets, Documents, Files and Services tabs, which led to pages they were refused. A document's or file's linked assets, licenses, services, domains and certificates were listed to them too.
- All Client Docs listed Credentials to agents who can't open it.
- Notifications: shared-link notifications reached agents without Support, with a link they couldn't open.
@@ -1379,7 +1394,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- Emails:
- New `functions/email.php`, loaded by `functions.php`: `emailHtml($email)` draws every email - `billingEmailHtml()` is now a wrapper for it that greets nobody without a name - with new fields `marker` (the reply line, first), `preview` (inbox preview text), `message` (`html`, `by`, `when`, `label` - HTML someone wrote, through `emailMessageHtml()`), `callout`, `contact` (the foot's contact line) and the `secondary` tone, and `name` '' for "Hello,". `EMAIL_TONES` and `EMAIL_REPLY_MARKER`. `emailMessageHtml($html)` purifies it (`documentHtmlPurify()`), drops the reply line, makes `/` links and images absolute, and caps image widths. `emailFrom($which)` (`system` or `ticket`, read from the settings as saved). `emailQueue($recipients, $subject, $email, $opts)` - recipients with `email`, `name`, `greet` and `footnote`, raw values, escaped for `addToMailQueue()`, each address once; `from` and `attachments` in `$opts`.
- `functions/ticket.php`: `ticketEmailContent($row, $kind, $opts)` (`created` with `how` agent, email or scheduled, `reply`, `resolved`, `closed`, `watcher`) and `ticketMailSend($ticket_id, $subject, $email, $opts)` (`attachments`, `to_contact`, `to_watchers`, `to`, `kind`) replace `ticketMailContactAndWatchers()` and `ticketMailCompany()`. Also `ticketEmailFrom()`, `ticketEmailUrl()`, `ticketEmailContactLine()`, `ticketEmailSla()` (in place of `getTicketSlaEmailNotice()`, removed) and `ticketMailClosed()`. `ticketMailResolved($ticket_id, $note)` takes the note as raw HTML. The reply handlers read the reply back from `ticket_replies` for the email, so it carries the signature as saved.
- - `recurringTicketRaise()` declares the ticket settings it reads as globals. `cron/nightly_tasks.php` calls `ticketMailClosed()` for each ticket it closes. `cron/ticket_email_parser.php`'s new-ticket reply and closed-ticket bounce, `api/v1/ticket_replies/create.php`, `add_ticket_watcher`, `bulk_ticket_reply`, `bulk_resolve_tickets` and `add_ticket_task_approver` go through them too.
+ - `recurringTicketRaise()` declares `$config_ticket_client_general_notifications` as a global, and tells the new-ticket address through `ticketMailNewTicketAddress()`. `cron/nightly_tasks.php` calls `ticketMailClosed()` for each ticket it closes. `cron/ticket_email_parser.php`'s new-ticket reply and closed-ticket bounce, `api/v1/ticket_replies/create.php`, `add_ticket_watcher`, `bulk_ticket_reply`, `bulk_resolve_tickets` and `add_ticket_task_approver` go through them too.
+ - `functions/email.php`: `emailSettings()` reads the mail settings from the database once per request, and `emailReady()` says whether outgoing mail is set up - so cron scripts, which keep escaped copies of the settings or none, can use them. `emailFrom()` reads through it. `emailQueue()` takes `cal_str` in `$opts`.
+ - `functions/ticket.php`, emails to agents: `ticketAgentEmailContent($row, $kind, $opts)` (`new` with `how` portal, email or scheduled; `replied` with `how`, `message` and `by`; `assigned` with `by`; `scheduled` with `when`, `where` and `cancelled`; `sla` with `track`, `breached` and `due`), `ticketAgentUrl($row)`, `ticketMailAgent($user_id)` (an active agent's email and name, or null), `ticketMailAgents($recipients, $subject, $email)` (from the ticket mailbox), `ticketMailNewTicketAddress($ticket_id, $how)`, `ticketMailAssigneeReplied($ticket_id, $how, $reply_html, $by)`, `ticketMailAssigned($user_id, $ticket_ids, $by)` and `recurringTicketMailAssigned($user_id, $subjects, $by)` - not when an agent assigns themselves. `ticketEmailContent()` has a `scheduled` kind, and `ticketMailSend()` passes `cal_str`. `ticketMailSchedule()` sends the agent's email through `ticketAgentEmailContent()` and the client's through `ticketMailSend()`, both with the calendar file.
+ - Callers: `ticketChangeAssignee()`, `recurringTicketChangeAssignee()`, `bulk_assign_ticket`, `add_ticket` and `edit_ticket` (`agent/post/ticket.php`), `recurringTicketNotifyAgent()` (`agent/post/recurring_ticket.php`), the portal's `add_ticket`, `add_ticket_comment` and `stripe_save_card` (`client/post.php`, the last through `billingEmailQueue()`), the parser's `addTicket()` and `addReply()`, `login.php`'s sign in alerts, `add_user`, `shareNotifyCreator()` and `test_email_smtp`.
+ - `cron/ticket_sla.php`: `sendSlaAlert($ticket, $track, $breached, $due)` - `$track` is `response` or `resolution` - in place of `sendSlaAlert($ticket, $subject_line, $body_line)`. `$from_email` and `$from_name` are gone.
+ - `functions/user.php`: `userSignInUrl()` (with the login key when one is required), `userMailAppName()`, `userMailNewAccount($name, $email, $password, $force_mfa)` (true when queued) and `userMailSignInAlert($name, $email, $kind, $ip, $user_agent)` (`new` or `mfa`). `userNotifyChange()` sends through `emailQueue()`. `userQueueMail()` is removed.
- Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`.
- Removed unused helpers and files:
- `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name
diff --git a/admin/post/settings_mail.php b/admin/post/settings_mail.php
index c9790c6bf..6e6a0df2f 100644
--- a/admin/post/settings_mail.php
+++ b/admin/post/settings_mail.php
@@ -217,41 +217,27 @@ if (isset($_POST['test_email_smtp'])) {
$test_email = intval($_POST['test_email']);
- if($test_email == 1) {
- $email_from = escapeSql($config_mail_from_email);
- $email_from_name = escapeSql($config_mail_from_name);
- } elseif ($test_email == 2) {
- $email_from = escapeSql($config_invoice_from_email);
- $email_from_name = escapeSql($config_invoice_from_name);
- } elseif ($test_email == 3) {
- $email_from = escapeSql($config_quote_from_email);
- $email_from_name = escapeSql($config_quote_from_name);
- } else {
- $email_from = escapeSql($config_ticket_from_email);
- $email_from_name = escapeSql($config_ticket_from_name);
+ // From the mailbox picked - the system's, invoices', quotes' or tickets'
+ $which = [1 => 'the system mailbox', 2 => 'the invoice mailbox', 3 => 'the quote mailbox'][$test_email] ?? 'the ticket mailbox';
+ $from = $test_email == 1 ? emailFrom() : ($test_email == 2 ? billingEmailFrom('invoice') : ($test_email == 3 ? billingEmailFrom('quote') : emailFrom('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);
}
- $email_to = escapeSql($_POST['email_to']);
- $subject = "Test email from ITFlow";
- $body = "This is a test email from ITFlow. If you are reading this, it worked!";
+ $sent = emailQueue([['email' => $to, 'greet' => '', 'footnote' => 'You get this because someone sent a test from Admin > Mail in ITFlow.']], "Test email from ITFlow", [
+ 'kind' => 'Test', 'tone' => 'success', 'preview' => "A test from $which",
+ 'title' => 'Mail is working',
+ 'intro' => ["This is a test email from ITFlow, sent from $which. If you are reading this, it worked."],
+ 'summary' => ['From' => trim($from['name'] . ' <' . $from['email'] . '>'), 'To' => $to, 'Sent' => date('D j M Y, g:i A')],
+ ], ['from' => $from]);
- $data = [
- [
- 'from' => $email_from,
- 'from_name' => $email_from_name,
- 'recipient' => $email_to,
- 'recipient_name' => 'Chap',
- 'subject' => $subject,
- 'body' => $body
- ]
- ];
-
- $mail = addToMailQueue($data);
-
- if ($mail === true) {
+ if ($sent) {
flashAlert("Test email queued! Check Admin > Mail queue");
} else {
- flashAlert("Failed to add test mail to queue", 'error');
+ flashAlert(escapeHtml($to) . " isn't an email address - nothing was queued", 'error');
}
redirect($mail_tab_redirect);
diff --git a/admin/post/users.php b/admin/post/users.php
index bea719cf5..89c42de3f 100644
--- a/admin/post/users.php
+++ b/admin/post/users.php
@@ -62,19 +62,12 @@ if (isset($_POST['add_user'])) {
}
// Their sign in details by email - the password goes as typed, so it is theirs to change
- if (!empty($_POST['send_email']) && !empty($config_smtp_host)) {
- $company_name = (string) mysqli_fetch_row(mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1"))[0];
- $login_url = "https://$config_base_url/login.php" . ($config_login_key_required == 1 ? "?key=$config_login_key_secret" : '');
- $mail = userQueueMail($email, $name, "Your new $company_name ITFlow account",
- 'Hello ' . escapeHtml($name) . ',
An ITFlow account has been set up for you. Please change your password after you sign in'
- . ($force_mfa ? ', and have an authenticator app ready - you will set up multi-factor authentication the first time' : '') . '.
'
- . 'Email: ' . escapeHtml($email) . '
Password: ' . escapeHtml($password) . '
Sign in at: ' . escapeHtml($login_url)
- . '
--
' . escapeHtml($company_name) . ' - Support
' . escapeHtml($config_ticket_from_email));
- if ($mail === true) {
+ if (!empty($_POST['send_email']) && emailReady()) {
+ if (userMailNewAccount($name, $email, $password, $force_mfa)) {
$notes[] = 'their sign in details were emailed to them';
} else {
$notes[] = 'the email with their sign in details could not be queued';
- logApp('Mail', 'error', 'Failed to queue the new account email for ' . $email . ": $mail");
+ logApp('Mail', 'error', 'Failed to queue the new account email for ' . escapeSql($email));
}
}
diff --git a/agent/post/recurring_ticket.php b/agent/post/recurring_ticket.php
index 570581b9c..1c6ab6057 100644
--- a/agent/post/recurring_ticket.php
+++ b/agent/post/recurring_ticket.php
@@ -76,7 +76,7 @@ function recurringTicketSetTasks($recurring_ticket_id) {
/** Tell an agent some recurring tickets are theirs now - not themselves, not nobody. $subjects are raw. */
function recurringTicketNotifyAgent($user_id, array $subjects) {
- global $mysqli, $session_user_id, $session_name, $session_company_name, $config_smtp_provider, $config_ticket_from_email, $config_ticket_from_name, $config_app_name;
+ global $mysqli, $session_user_id, $session_name;
$user_id = intval($user_id);
if (!$user_id || $user_id == $session_user_id || !$subjects) {
return;
@@ -85,18 +85,7 @@ function recurringTicketNotifyAgent($user_id, array $subjects) {
$what = $count === 1 ? 'Recurring ticket ' . escapeSql($subjects[0]) . ' has' : "$count recurring tickets have";
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Recurring Ticket', notification = '$what been assigned to you by " . escapeSql($session_name) . "', notification_action = 'recurring_tickets.php?assigned_agent=$user_id', notification_user_id = $user_id");
- $agent = quickPickAgent($user_id);
- if ($agent && !empty($config_smtp_provider) && filter_var($agent['user_email'], FILTER_VALIDATE_EMAIL)) {
- $list = implode('
', array_map(function ($subject) { return escapeSql(escapeHtml($subject)); }, $subjects));
- addToMailQueue([[
- 'from' => escapeSql($config_ticket_from_email),
- 'from_name' => escapeSql($config_ticket_from_name),
- 'recipient' => escapeSql($agent['user_email']),
- 'recipient_name' => escapeSql($agent['user_name']),
- 'subject' => escapeSql("$config_app_name - $count recurring ticket" . ($count === 1 ? '' : 's') . " assigned to you"),
- 'body' => "Hi " . escapeSql($agent['user_name']) . ",
" . escapeSql($session_name) . " assigned $count recurring ticket" . ($count === 1 ? '' : 's') . " to you:
$list
Thanks,
" . escapeSql($session_name) . "
" . escapeSql($session_company_name),
- ]]);
- }
+ recurringTicketMailAssigned($user_id, $subjects, stripslashes($session_name));
}
if (isset($_POST['add_recurring_ticket'])) {
diff --git a/agent/post/ticket.php b/agent/post/ticket.php
index 7f571a306..23dac0033 100644
--- a/agent/post/ticket.php
+++ b/agent/post/ticket.php
@@ -180,6 +180,7 @@ if (isset($_POST['add_ticket'])) {
if ($assigned_to) {
$row = ticketRow($ticket_id);
ticketNotifyAgent($assigned_to, $row, "Ticket " . ticketReference($row) . " - Subject: " . $row['ticket_subject'] . " has been assigned to you by $session_name");
+ ticketMailAssigned($assigned_to, [$ticket_id], stripslashes($session_name));
}
logAudit("Ticket", "Create", "$session_name created ticket $ticket_prefix$ticket_number - $subject", $client_id, $ticket_id);
@@ -290,6 +291,7 @@ if (isset($_POST['edit_ticket'])) {
if ($assigned_to) {
logTicketHistory($ticket_id, "$session_name assigned the ticket to " . escapeSql(getFieldById('users', $assigned_to, 'user_name')));
ticketNotifyAgent($assigned_to, $row, "Ticket " . ticketReference($row) . " - Subject: " . $subject_input . " has been assigned to you by $session_name");
+ ticketMailAssigned($assigned_to, [$ticket_id], stripslashes($session_name));
} else {
logTicketHistory($ticket_id, "$session_name unassigned the ticket");
}
@@ -620,7 +622,7 @@ if (isset($_POST['bulk_assign_ticket'])) {
$agent_name_sql = escapeSql($agent_name);
$count = $skipped = 0;
- $assigned_list = '';
+ $assigned_ids = [];
foreach (ticketsForBulk(2) as $ticket_id => $row) {
// A closed ticket keeps who closed it
if (!empty($row['ticket_closed_at'])) {
@@ -644,7 +646,7 @@ if (isset($_POST['bulk_assign_ticket'])) {
triggerCustomAction('ticket_assign', $ticket_id);
- $assigned_list .= escapeSql(ticketReference($row) . ' - ' . $row['ticket_subject']) . "
";
+ $assigned_ids[] = $ticket_id;
$count++;
}
@@ -652,18 +654,7 @@ if (isset($_POST['bulk_assign_ticket'])) {
if ($count && $assign_to && $assign_to != $session_user_id) {
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Ticket', notification = '$count ticket" . ($count === 1 ? ' has' : 's have') . " been assigned to you by $session_name', notification_action = '/agent/tickets.php?assigned=$assign_to', notification_user_id = $assign_to");
- $agent_email = (string) getFieldById('users', $assign_to, 'user_email');
- if (!empty($config_smtp_provider) && filter_var($agent_email, FILTER_VALIDATE_EMAIL)) {
- $company_name = escapeSql($session_company_name);
- addToMailQueue([[
- 'from' => escapeSql($config_ticket_from_email),
- 'from_name' => escapeSql($config_ticket_from_name),
- 'recipient' => escapeSql($agent_email),
- 'recipient_name' => $agent_name_sql,
- 'subject' => escapeSql($config_app_name) . " - $count tickets have been assigned to you",
- 'body' => "Hi $agent_name_sql,
$session_name assigned $count tickets to you!
$assigned_list
Thanks,
$session_name
$company_name",
- ]]);
- }
+ ticketMailAssigned($assign_to, $assigned_ids, stripslashes($session_name));
}
flashAlert("Assigned $count ticket" . ($count === 1 ? '' : 's') . " to " . escapeHtml($agent_name) . "" . ($skipped ? ", $skipped closed left as they were" : ''));
diff --git a/client/post.php b/client/post.php
index 2f8bc686e..343222cab 100644
--- a/client/post.php
+++ b/client/post.php
@@ -94,27 +94,8 @@ if (isset($_POST['add_ticket'])) {
// Screenshots and files sent with it belong to the ticket itself
saveTicketAttachments($ticket_id, null, 'file');
- // Notify agent DL of the new ticket, if populated with a valid email
- if ($config_ticket_new_ticket_notification_email) {
-
- $client_name = escapeSql($session_client_name);
-
- $email_subject = "ITFlow - New Ticket - $client_name: $subject";
- $email_body = "Hello,
This is a notification that a new ticket has been raised in ITFlow.
Client: $client_name
Priority: $priority
Link: https://$config_base_url/agent/ticket.php?ticket_id=$ticket_id&client_id=$session_client_id
$subject
$details";
-
- // Queue Mail
- $data = [
- [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $config_ticket_new_ticket_notification_email,
- 'recipient_name' => $config_ticket_from_name,
- 'subject' => $email_subject,
- 'body' => $email_body,
- ]
- ];
- addToMailQueue($data);
- }
+ // The new-ticket address (Admin > Ticket), when there is one
+ ticketMailNewTicketAddress($ticket_id, 'portal');
// The primary and technical contacts who asked to hear about new tickets at their company
ticketMailColleaguesNew($ticket_id);
@@ -164,38 +145,8 @@ if (isset($_POST['add_ticket_comment'])) {
}
- // Get ticket details & Notify the assigned tech (if any)
- $ticket_details = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_name, ticket_assigned_to, ticket_number, ticket_subject FROM tickets LEFT JOIN clients ON ticket_client_id = client_id WHERE ticket_id = $ticket_id LIMIT 1"));
-
- $ticket_number = intval($ticket_details['ticket_number']);
- $ticket_assigned_to = intval($ticket_details['ticket_assigned_to']);
- $ticket_subject = escapeSql($ticket_details['ticket_subject']);
- $client_name = escapeSql($ticket_details['client_name']);
-
- if ($ticket_details && $ticket_assigned_to !== 0) {
-
- // Get tech details
- $tech_details = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_email, user_name FROM users WHERE user_id = $ticket_assigned_to LIMIT 1"));
- $tech_email = escapeSql($tech_details['user_email']);
- $tech_name = escapeSql($tech_details['user_name']);
-
- $subject = "$config_app_name Ticket updated - [$config_ticket_prefix$ticket_number] $ticket_subject";
- $body = "Hello $tech_name,
A new reply has been added to the below ticket, check ITFlow for full details.
Client: $client_name
Ticket: $config_ticket_prefix$ticket_number
Subject: $ticket_subject
https://$config_base_url/agent/ticket.php?ticket_id=$ticket_id&client_id=$session_client_id";
-
- $data = [
- [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $tech_email,
- 'recipient_name' => $tech_name,
- 'subject' => $subject,
- 'body' => $body
- ]
- ];
-
- addToMailQueue($data);
-
- }
+ // The agent it is assigned to, with what they wrote
+ ticketMailAssigneeReplied($ticket_id, 'portal', (string) getFieldById('ticket_replies', $ticket_reply_id, 'ticket_reply'), stripslashes($session_contact_name));
// Store any attached files against this reply
saveTicketAttachments($ticket_id, $ticket_reply_id, 'file');
@@ -1269,39 +1220,16 @@ if (isset($_GET['stripe_save_card'])) {
redirect("saved_payment_methods.php");
}
- // Email Confirmation
- $sql_settings = mysqli_query($mysqli, "
- SELECT company_name, company_phone, company_phone_country_code, config_invoice_from_email,
- config_invoice_from_name, config_smtp_host FROM companies, settings
- WHERE companies.company_id = settings.company_id
- AND companies.company_id = 1
- ");
- $row = mysqli_fetch_assoc($sql_settings);
-
- $company_name = escapeSql($row['company_name']);
- $company_phone = escapeSql(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code']));
- $config_invoice_from_email = escapeSql($row['config_invoice_from_email']);
- $config_invoice_from_name = escapeSql($row['config_invoice_from_name']);
-
- if (!empty($row['config_smtp_host'])) {
- $subject = "Payment method saved";
- $body = "Hello $session_contact_name
- Were writing to confirm that your payment details have been securely stored with Stripe our trusted payment processor.
- You authorized us to automatically bill your card ($saved_payment_description) for future invoices.
- You may update or remove your payment method at any time via the client portal.
- Thank you for your business!
- --
$company_name - Billing Department
$config_invoice_from_email
$company_phone";
-
- $data = [[
- 'from' => $config_invoice_from_email,
- 'from_name' => $config_invoice_from_name,
- 'recipient' => $session_contact_email,
- 'recipient_name' => $session_contact_name,
- 'subject' => $subject,
- 'body' => $body
- ]];
-
- $mail = addToMailQueue($data);
+ // Their confirmation, in the billing emails' look - from the invoice mailbox
+ if (billingEmailReady()) {
+ billingEmailQueue([['email' => stripslashes($session_contact_email), 'name' => stripslashes($session_contact_name)]], 'Payment method saved', [
+ 'kind' => 'Payment method', 'tone' => 'success', 'preview' => 'Your card is saved for future invoices',
+ 'title' => 'Your payment method is saved',
+ 'intro' => ['Your payment details are stored securely with Stripe, our payment processor, and you authorized us to bill this card automatically for future invoices.'],
+ 'summary' => ['Card' => ucfirst(stripslashes((string) $card_brand)) . ' ending ' . stripslashes((string) $last4), 'Expires' => sprintf('%02d/%d', intval($exp_month), intval($exp_year))],
+ 'after' => ['You can change or remove it at any time in the client portal.', 'Thank you for your business!'],
+ 'footnote' => billingEmailFootnote(stripslashes((string) $session_client_name)),
+ ]);
}
logAudit("Stripe", "Update", "$session_contact_name saved payment method ($saved_payment_description) (PM: $payment_method_id)", $session_client_id);
diff --git a/cron/ticket_email_parser.php b/cron/ticket_email_parser.php
index ec80d9cf8..ff88bd6fa 100644
--- a/cron/ticket_email_parser.php
+++ b/cron/ticket_email_parser.php
@@ -71,7 +71,7 @@ $max_inline_embed_bytes = 2097152; // 2 MB - larger inline images are saved as r
* Ticket / Reply helpers (unchanged)
* ------------------------------------------------------------------ */
function addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message, $attachments, $original_message_file, $ccs) {
- global $mysqli, $config_app_name, $company_name, $company_phone, $config_ticket_prefix, $config_ticket_client_general_notifications, $config_ticket_new_ticket_notification_email, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $config_ticket_default_billable, $allowed_extensions;
+ global $mysqli, $config_app_name, $company_name, $company_phone, $config_ticket_prefix, $config_ticket_client_general_notifications, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $config_ticket_default_billable, $allowed_extensions;
$bad_pattern = "/do[\W_]*not[\W_]*reply|no[\W_]*reply/i"; // Email addresses to ignore
// Atomically increment and get the new ticket number
@@ -164,32 +164,8 @@ function addTicket($contact_id, $contact_name, $contact_email, $client_id, $date
['kind' => 'created', 'to_watchers' => false, 'to' => [['email' => stripslashes($contact_email), 'name' => stripslashes($contact_name)]]]);
}
- // Internal email
- $data = [];
- if ($config_ticket_new_ticket_notification_email) {
- if ($client_id == 0) {
- $client_name = "Guest";
- $client_uri = '';
- } else {
- $client_sql = mysqli_query($mysqli, "SELECT client_name FROM clients WHERE client_id = $client_id");
- $client_row = mysqli_fetch_assoc($client_sql);
- $client_name = escapeSql($client_row['client_name']);
- $client_uri = "&client_id=$client_id";
- }
- $email_subject = "$config_app_name - New Ticket - $client_name: $subject";
- $email_body = "Hello,
This is a notification that a new ticket has been raised in ITFlow.
Client: $client_name
Priority: Low (email parsed)
Link: https://$config_base_url/agent/ticket.php?ticket_id=$id$client_uri
--------------------------------
$subject
$message";
-
- $data[] = [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $config_ticket_new_ticket_notification_email,
- 'recipient_name' => $config_ticket_from_name,
- 'subject' => $email_subject,
- 'body' => mysqli_real_escape_string($mysqli, $email_body)
- ];
- }
-
- addToMailQueue($data);
+ // The new-ticket address (Admin > Ticket), when there is one
+ ticketMailNewTicketAddress($id, 'email');
// The client's primary and technical contacts who asked to hear about new tickets
ticketMailColleaguesNew($id);
@@ -246,7 +222,6 @@ function addReply($from_email, $date, $subject, $ticket_number, $message, $attac
if ($row) {
$ticket_id = intval($row['ticket_id']);
- $ticket_subject = escapeSql($row['ticket_subject']);
$ticket_status = escapeSql($row['ticket_status']);
$ticket_reply_contact = intval($row['ticket_contact_id']);
$ticket_contact_email = escapeSql($row['contact_email']);
@@ -256,7 +231,6 @@ function addReply($from_email, $date, $subject, $ticket_number, $message, $attac
} else {
$client_uri = '';
}
- $client_name = escapeSql($row['client_name']);
if ($ticket_status == 5) {
$config_ticket_prefix_esc = mysqli_real_escape_string($mysqli, $config_ticket_prefix);
@@ -317,33 +291,9 @@ function addReply($from_email, $date, $subject, $ticket_number, $message, $attac
}
}
- $ticket_assigned_to_sql = mysqli_query($mysqli, "SELECT ticket_assigned_to FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
- if ($ticket_assigned_to_sql) {
- $row3 = mysqli_fetch_assoc($ticket_assigned_to_sql);
- $ticket_assigned_to = intval($row3['ticket_assigned_to']);
-
- if ($ticket_assigned_to) {
- $tech_sql = mysqli_query($mysqli, "SELECT user_email, user_name FROM users WHERE user_id = $ticket_assigned_to LIMIT 1");
- $tech_row = mysqli_fetch_assoc($tech_sql);
- $tech_email = escapeSql($tech_row['user_email']);
- $tech_name = escapeSql($tech_row['user_name']);
-
- $email_subject = "$config_app_name - Ticket updated - [$config_ticket_prefix$ticket_number] $ticket_subject";
- $email_body = "Hello $tech_name,
A new reply has been added to the below ticket.
Client: $client_name
Ticket: $config_ticket_prefix$ticket_number
Subject: $ticket_subject
Link: https://$config_base_url/agent/ticket.php?ticket_id=$ticket_id$client_uri
--------------------------------
$message_esc";
-
- $data = [
- [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $tech_email,
- 'recipient_name' => $tech_name,
- 'subject' => mysqli_real_escape_string($mysqli, $email_subject),
- 'body' => mysqli_real_escape_string($mysqli, $email_body)
- ]
- ];
- addToMailQueue($data);
- }
- }
+ // The agent it is assigned to, with what they wrote
+ $replied_by = intval($ticket_reply_contact) ? (string) getFieldById('contacts', intval($ticket_reply_contact), 'contact_name') : '';
+ ticketMailAssigneeReplied($ticket_id, 'email', (string) getFieldById('ticket_replies', $reply_id, 'ticket_reply'), $replied_by !== '' ? $replied_by : stripslashes($from_email));
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 2, ticket_resolved_at = NULL WHERE ticket_id = $ticket_id AND ticket_client_id = $client_id LIMIT 1");
resetTicketResolutionSla($ticket_id);
diff --git a/cron/ticket_sla.php b/cron/ticket_sla.php
index 8b5d36e56..e9389fa2c 100644
--- a/cron/ticket_sla.php
+++ b/cron/ticket_sla.php
@@ -55,8 +55,6 @@ if ($warning_percent < 1 || $warning_percent > 99) {
}
$sla_notification_email = trim(strval($sla_settings['notification_email']));
-$from_email = $sla_settings['ticket_from_email'];
-$from_name = $sla_settings['ticket_from_name'];
$now = time();
@@ -89,55 +87,35 @@ while ($running = mysqli_fetch_assoc($sql_running)) {
syncTicketSlaClock(intval($running['ticket_id']));
}
-// Queue in-app + email notifications for an SLA event
-function sendSlaAlert($ticket, $subject_line, $body_line)
+// Queue in-app + email notifications for an SLA event - to the SLA alert address (Admin > SLAs) and
+// the agent the ticket is assigned to, in the email look (ticketAgentEmailContent()). $track is
+// response or resolution; $breached true once it was missed; $due when it is or was due.
+function sendSlaAlert($ticket, $track, $breached, $due)
{
- global $mysqli, $sla_notification_email, $from_email, $from_name, $config_base_url;
+ global $sla_notification_email;
$ticket_id = intval($ticket['ticket_id']);
$client_id = intval($ticket['ticket_client_id']);
-
- $ticket_ref = "{$ticket['ticket_prefix']}{$ticket['ticket_number']}";
- $ticket_subject = strval($ticket['ticket_subject']);
+ $row = ticketRow($ticket_id);
+ if (!$row) {
+ return;
+ }
+ $ticket_ref = ticketReference($row);
+ $subject_line = ucfirst($track) . ' SLA ' . ($breached ? 'breached' : 'at risk');
// appNotify inserts what it is given - escape at the boundary
- appNotify("Ticket SLA", escapeSql("$subject_line - $ticket_ref - $ticket_subject"), "/agent/ticket.php?ticket_id=$ticket_id", $client_id, $ticket_id);
+ appNotify("Ticket SLA", escapeSql("$subject_line - $ticket_ref - " . $row['ticket_subject']), "/agent/ticket.php?ticket_id=$ticket_id", $client_id, $ticket_id);
- // addToMailQueue also inserts raw, and the body's link markup contains
- // single quotes - escape whole strings once here. The body keeps its HTML,
- // so it gets mysqli_real_escape_string directly (escapeSql strips tags),
- // same as the ticket email parser does for its bodies.
- $email_subject = escapeSql("$subject_line: $ticket_ref - $ticket_subject");
- $email_body = mysqli_real_escape_string($mysqli, "Hello,
$body_line
Ticket: $ticket_ref
Subject: $ticket_subject
View ticket");
-
- $email_data = [];
-
- if (!empty($sla_notification_email)) {
- $email_data[] = [
- 'from' => $from_email,
- 'from_name' => escapeSql($from_name),
- 'recipient' => $sla_notification_email,
- 'recipient_name' => 'SLA Notifications',
- 'subject' => $email_subject,
- 'body' => $email_body,
- ];
+ $recipients = [];
+ if (filter_var($sla_notification_email, FILTER_VALIDATE_EMAIL)) {
+ $recipients[] = ['email' => $sla_notification_email, 'greet' => '', 'footnote' => 'You get this at the SLA alert address in Admin > SLAs.'];
}
-
- // Assigned agent (skip a duplicate if they are also the notification address)
- if (!empty($ticket['user_email']) && strtolower($ticket['user_email']) != strtolower($sla_notification_email)) {
- $email_data[] = [
- 'from' => $from_email,
- 'from_name' => escapeSql($from_name),
- 'recipient' => $ticket['user_email'],
- 'recipient_name' => escapeSql(strval($ticket['user_name'])),
- 'subject' => $email_subject,
- 'body' => $email_body,
- ];
- }
-
- if (!empty($email_data)) {
- addToMailQueue($email_data);
+ // The assigned agent - once, if they are also the alert address
+ if ($agent = ticketMailAgent($row['ticket_assigned_to'])) {
+ $recipients[] = $agent + ['footnote' => "You get this as the agent $ticket_ref is assigned to."];
}
+ ticketMailAgents($recipients, "$subject_line: $ticket_ref - " . $row['ticket_subject'],
+ ticketAgentEmailContent($row, 'sla', ['track' => $track, 'breached' => $breached, 'due' => $due]));
}
// --- Response SLA track ---
@@ -164,13 +142,13 @@ while ($ticket = mysqli_fetch_assoc($sql_response)) {
if ($now >= $due) {
// Breached without a response - the verdict is final, record the miss
mysqli_query($mysqli, "UPDATE tickets SET ticket_response_sla_alert_stage = 2, ticket_response_sla_met = 0 WHERE ticket_id = $ticket_id");
- sendSlaAlert($ticket, "Response SLA breached", "The response SLA on this ticket was missed (due {$ticket['ticket_response_due_at']}).");
+ sendSlaAlert($ticket, 'response', true, $ticket['ticket_response_due_at']);
} elseif ($stage < 1 && $warning_percent) {
$warn_at = strtotime(addBusinessMinutes($ticket['ticket_created_at'], floor(intval($ticket['sla_response_minutes']) * $warning_percent / 100)));
if ($now >= $warn_at) {
mysqli_query($mysqli, "UPDATE tickets SET ticket_response_sla_alert_stage = 1 WHERE ticket_id = $ticket_id");
- sendSlaAlert($ticket, "Response SLA at risk", "This ticket is approaching its response SLA (due {$ticket['ticket_response_due_at']}).");
+ sendSlaAlert($ticket, 'response', false, $ticket['ticket_response_due_at']);
}
}
}
@@ -199,14 +177,14 @@ while ($ticket = mysqli_fetch_assoc($sql_resolution)) {
if ($now >= $due) {
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_alert_stage = 2, ticket_resolution_sla_met = 0 WHERE ticket_id = $ticket_id");
- sendSlaAlert($ticket, "Resolution SLA breached", "The resolution SLA on this ticket was missed (due {$ticket['ticket_resolution_due_at']}).");
+ sendSlaAlert($ticket, 'resolution', true, $ticket['ticket_resolution_due_at']);
} elseif ($stage < 1 && $warning_percent) {
// Measured against consumed clock time, so paused spells don't warn early
$warn_after_minutes = floor(intval($ticket['sla_resolution_minutes']) * $warning_percent / 100);
if (getTicketSlaConsumedMinutes($ticket_id) >= $warn_after_minutes) {
mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_alert_stage = 1 WHERE ticket_id = $ticket_id");
- sendSlaAlert($ticket, "Resolution SLA at risk", "This ticket is approaching its resolution SLA (due {$ticket['ticket_resolution_due_at']}).");
+ sendSlaAlert($ticket, 'resolution', false, $ticket['ticket_resolution_due_at']);
}
}
}
diff --git a/functions/email.php b/functions/email.php
index 7d316daeb..81d834836 100644
--- a/functions/email.php
+++ b/functions/email.php
@@ -170,17 +170,29 @@ function emailHtml(array $email) {
. '