Repository navigation
Basic IMAP Sent Copy Developer Guide
How a Basic IMAP mailbox files a copy of each email it sends in its Sent folder (#270, suggested in #262): the files, the PHP from a reply to the IMAP APPEND, how the folder is chosen, which sends are filed and which are not, the Test button, the schema, the JavaScript, and the tests.
- For the person setting it up: Basic IMAP mailboxes - Save a copy of sent mail
- The IMAP client it builds on: Basic IMAP client - Developer Guide Β· Testing
- Changelog row #2804, commit
8f64e1ade, for 5.0.0.
Microsoft 365 files what FreeITSM sends because the Graph sendMail call says 'saveToSentItems' => true. Gmail files sent mail by itself. A Basic IMAP mailbox sends through SMTP, and SMTP only delivers - nothing ends up in Sent. Anyone reading the support mailbox in Thunderbird or webmail could not see what the service desk replied.
The fix is what every desktop mail client does: after sending, open an IMAP connection and APPEND the same message to the Sent folder.
It is off by default, per mailbox, because some SMTP servers already file sent mail (Gmail SMTP, Microsoft 365 SMTP AUTH, some cPanel hosts). Turning it on there gives two copies of everything, and FreeITSM cannot tell from the outside which kind of server it is talking to.
| File | What changed |
|---|---|
includes/imap_client.php |
New method ImapClient::append($folder, $message, $flags) - IMAP APPEND as a synchronising literal |
includes/mailbox_imap.php |
imapSmtpSend() gains bool $saveCopy = true, keeps the message before dot-stuffing, and files it after a successful QUIT. New: imapSentCopyOutcome(), imapFindSentFolder(), imapSaveSentCopy()
|
includes/template_email.php |
internalTicketEmail() passes false - internal notifications are never filed |
includes/email_log.php |
New route 'sent_copy' => 'Copy to Sent folder' (failures only) |
includes/mailbox_send.php |
mailboxTestResolve() carries the form's toggle and folder, and decides whether the copy may be tried during Test |
api/tickets/test_mailbox_send.php |
Returns sent_copy - {folder, error} or null
|
api/tickets/save_mailbox.php |
Saves save_sent_copy and sent_folder, guarded on the column existing |
api/tickets/get_mailboxes.php |
Reads them, guarded the same way |
includes/db_verify_schema.php, database/freeitsm.sql
|
Two columns on target_mailboxes
|
tickets/settings/index.php |
The toggle, the Sent folder row with Verify, toggleSentFolder(), verifySentFolder(), the Test result |
lang/{en,de,el,es,id,pl}/tickets.php |
modals.mailbox.save_sent_copy, save_sent_copy_help, sent_folder, sent_folder_placeholder, sent_folder_help; mailbox_test.copy_saved; help settings.email_sent_copy_note
|
tickets/help.php |
The help note under Email importing in detail |
tests/mailbox-sent-copy.php |
New. 23 checks with fake SMTP and IMAP servers - no database, no Docker |
tests/imap-client-live.php |
New section against real Dovecot and GreenMail (104 checks in all) |
send_email.php / sendTemplateEmail() / workflow / ssSendSystemEmail() / mailboxSendSimple()
-> imapSmtpSend($mailbox, $to, $cc, $subject, $html, $parts) $saveCopy defaults to true
SMTP: EHLO, STARTTLS, AUTH, MAIL FROM, RCPT TO, DATA, message, QUIT
-> only after QUIT succeeded, and only if $saveCopy && $mailbox['save_sent_copy']:
imapSaveSentCopy($mailbox, $raw, ...) never throws
new ImapClient -> connect -> login
folder: sent_folder matched to the server's spelling, or imapFindSentFolder()
$client->append($folder, $raw, ['\\Seen']) IMAP APPEND
logout
failure -> error_log + emailLogFailed(..., 'sent_copy', ...)
includes/mailbox_imap.php:
function imapSmtpSend(array $mailbox, string $to, string $cc, string $subject, string $htmlBody, array $parts = [], bool $saveCopy = true): void {
imapSentCopyOutcome(false); // forget the last send's outcome // Dot-stuff and terminate. The copy for Sent is the message BEFORE stuffing:
// stuffing is SMTP's transport encoding, and APPEND stores the message as it is.
$raw = $message;
$message = preg_replace('/^\./m', '..', $message);
fwrite($fp, $message . "\r\n.\r\n");
imapSmtpExpect($fp, [250]);
imapSmtpCommand($fp, 'QUIT', [221]);
} finally {
fclose($fp);
}
// Sent - only now, so a failed send is never filed as sent. Best effort: never throws.
if ($saveCopy && !empty($mailbox['save_sent_copy'])) {
imapSaveSentCopy($mailbox, $raw, implode(', ', array_merge($toList, $ccList)), $subject);
}
}Why the copy is $raw, not $message. SMTP ends the message body with a line holding a single ., so any line the message itself starts with a dot is sent as .. ("dot-stuffing") and the receiving server takes the extra dot off again. That is SMTP's transport encoding, not part of the email. IMAP APPEND sends the message as a counted literal and stores the bytes as they are - append the stuffed copy and every such line in Sent would start with two dots. Both tests check this with a body line that starts with a dot.
Why after QUIT. Any SMTP failure throws out of the try before the copy is reached, so an email that did not go is never filed as sent. tests/mailbox-sent-copy.php section 6 points SMTP at a closed port with the toggle on and checks nothing was appended.
$raw is the whole message mimeBuildMessage() built - headers (Date, Message-ID, To, Cc, the encoded subject) and every attachment and inline image - so the copy in Sent is the email the customer received, byte for byte. Bcc does not exist on this path.
includes/imap_client.php:
public function append(string $folder, string $message, array $flags = ['\\Seen']): void
{
$this->command('APPEND ' . imapString(imapFolderEncode($folder))
. ($flags ? ' (' . implode(' ', $flags) . ')' : '')
. ' {' . strlen($message) . "}\r\n" . $message);
}- The folder name goes through
imapFolderEncode()(UTF-8 to modified UTF-7, soGesendete ObjekteorΓlements envoyΓ©swork) andimapString()(quoted, or a literal if it has unusual bytes). - The message is a synchronising literal.
sendCommand()splits the command at{n}\r\n, waits for the server's+, and only then writes the bytes:
// Wait for "+ ..." before sending the literal; a tagged NO/BAD means refused.
while (true) {
$line = $this->readLogicalLine();
if ($line !== '' && $line[0] === '+') {
break;
}
if (strncmp($line, $tag . ' ', strlen($tag) + 1) === 0) {
throw new ImapException(imapShorten(substr($line, strlen($tag) + 1)));
}
}So a server that refuses (no such folder, over quota) says so before a large message is uploaded. After the literal, $pos moves past its bytes, so a message that happens to contain {5}\r\n is never mistaken for another literal.
Recorded against the Dovecot test container (the message shortened to 98 bytes):
C: A0003 LIST "" "*"
S: * LIST (\HasNoChildren \Sent) "/" Sent
S: * LIST (\HasNoChildren \Drafts) "/" Drafts
S: * LIST (\HasNoChildren \Junk) "/" Junk
S: * LIST (\HasNoChildren \Trash) "/" Trash
S: * LIST (\HasNoChildren) "/" INBOX
S: A0003 OK List completed (0.001 + 0.000 secs).
C: A0004 APPEND "Sent" (\Seen) {98}
S: + OK
C: (the 98 bytes of the message)
S: A0004 OK [APPENDUID 1794438214 1] Append completed (0.004 + 0.001 + 0.003 secs).
C: A0005 APPEND "Gone" (\Seen) {98}
S: A0005 NO [TRYCREATE] Mailbox doesn't exist: Gone (0.001 + 0.000 secs).
The second APPEND shows a refusal arriving instead of +: the bytes are never sent, and ImapException carries the server's own words into the log.
\Seen is set because a copy of something the desk sent is not new mail - without it the Sent folder would show every reply as unread in Thunderbird.
function imapFindSentFolder(ImapClient $client): ?string {
$folders = $client->listFolders();
foreach ($folders as $f) {
if (in_array('\\SENT', array_map('strtoupper', $f['flags']), true)) {
return $f['name'];
}
}
// No SPECIAL-USE: the names servers actually use, most common first.
foreach (['Sent', 'Sent Items', 'Sent Messages', 'Sent Mail', 'INBOX.Sent', 'INBOX/Sent', 'INBOX.Sent Items'] as $want) {
foreach ($folders as $f) {
if (strcasecmp($f['name'], $want) === 0) {
return $f['name'];
}
}
}
return null;
}-
SPECIAL-USE first (RFC 6154): the folder
LISTmarks\Sent. Dovecot, Gmail, Exchange and most hosts mark one, whatever it is called in the user's language. - Then common names, case-insensitive. GreenMail, for example, marks nothing - the live test prints which way the folder was found on each server.
-
Neither:
null, andimapSaveSentCopy()fails with "the server marks no folder as Sent - name the folder in the mailbox settings".
// The server's own spelling, so "sent" finds "Sent" and "Inbox/Sent" finds "INBOX.Sent".
$folder = $want;
foreach ($client->listFolders() as $f) {
$alt = $f['delimiter'] !== '' ? str_replace($f['delimiter'], '/', $f['name']) : $f['name'];
if (strcasecmp($f['name'], $want) === 0 || strcasecmp($alt, $want) === 0) {
$folder = $f['name'];
break;
}
}Folder names on most servers are case-sensitive except INBOX, so APPEND "sent" would fail where APPEND "Sent" works. Matching to the LIST name, with the delimiter normalised to /, accepts what a person is likely to type - the same rule imapVerifyFolder() uses for the Verify button, so Verify and the copy agree. A name that matches nothing is still tried as typed, and the server's [TRYCREATE] refusal names it. The folder is never created - a typo should not grow a new folder on the customer's server.
function imapSaveSentCopy(array $mailbox, string $raw, string $to, string $subject): array {
$folder = null;
try {
$client = new ImapClient(
(string) ($mailbox['imap_server'] ?? ''),
(int) ($mailbox['imap_port'] ?? 993),
(string) ($mailbox['imap_encryption'] ?? 'ssl')
);
$client->connect();
try {
$client->login((string) ($mailbox['imap_username'] ?? ''), (string) ($mailbox['imap_password'] ?? '')); $client->append($folder, $raw, ['\\Seen']);
} finally {
$client->logout();
}
return imapSentCopyOutcome(['folder' => $folder, 'error' => null]);
} catch (Throwable $e) {
$why = 'Sent, but the copy could not be saved' . ($folder !== null ? ' to "' . $folder . '"' : '') . ': ' . $e->getMessage();
error_log('IMAP sent copy (mailbox ' . ($mailbox['id'] ?? '?') . '): ' . $why);
try {
require_once __DIR__ . '/email_log.php';
emailLogFailed(function_exists('connectToDatabase') ? connectToDatabase() : null, $mailbox, 'sent_copy', $to, $subject, $why);
} catch (Throwable) { /* the log must never break a send */ }
return imapSentCopyOutcome(['folder' => $folder, 'error' => $why]);
}
}The contract:
-
By the time this runs, the email has gone. Throwing would make the caller report a failed send - an analyst would resend a reply the customer already has, a workflow run would be marked failed. So every failure (connection, sign-in, no folder,
APPENDrefused) is caught. -
Where a failure goes:
error_log, and a Failed row in the outbound email log under routesent_copy("Copy to Sent folder"), with the recipients and subject of the email it was for and the reason. Mailbox Activity β Outbound shows it; Failures only finds it. Success writes nothing - the email's own route already has its Sent row. - Even the log is guarded: a database problem while logging must not turn a sent email into an error.
- It uses the IMAP login (
imap_username/imap_password), never the separate SMTP login -APPENDgoes to the IMAP server. - It is a second connection per send, opened and closed straight away. An email with large attachments is uploaded twice, once by SMTP and once by
APPEND.
function imapSentCopyOutcome($set = null): ?array {
static $last = null;
if ($set === false) {
$last = null;
} elseif (is_array($set)) {
$last = $set;
}
return $last;
}A small per-request memory: imapSmtpSend() clears it at the start (imapSentCopyOutcome(false)), imapSaveSentCopy() records the result, and anything that wants to know reads it with no argument. null means no copy was attempted (toggle off, $saveCopy false, or the send failed). Only the Test endpoint reads it today; the signature of imapSmtpSend() stayed void so none of its callers changed.
Every imapSmtpSend() call site files the copy when the toggle is on, except one:
| Call site | What it sends | Filed? |
|---|---|---|
api/tickets/send_email.php |
analyst replies and forwards (with attachments) | yes |
includes/template_email.php - sendTemplateEmail()
|
template emails (ticket logged, resolved, CSAT ...) | yes |
workflow/includes/engine.php - action_send_email()
|
workflow Send email | yes |
includes/self_service_email.php - ssSendSystemEmail()
|
portal and system email (password reset, verification, training, task assignment ...) through the first mailbox able to send | yes |
includes/mailbox_send.php - mailboxSendSimple()
|
the mailbox's own oversized-email alert and auto-reply (includes/attachment_limits.php), and the Test button |
yes |
includes/template_email.php - internalTicketEmail()
|
internal notifications to analysts | no |
imapSmtpSend($mailbox, $to, '', $subject, $bodyHtml, [], false); // internal notification: never filed in Sent (as saveToSentItems false below)This is the same rule the Microsoft 365 route has always had - a few lines further down in the same function:
'saveToSentItems' => false, // internal notification, don't clutter Sent ItemsThe rule for new code: if the Graph branch of your send says saveToSentItems => false, pass false as $saveCopy on the IMAP branch. The default is true so that every existing caller files, matching their Graph branches, which all say true.
Every caller loads the mailbox with SELECT * / SELECT tm.* and decryptMailboxRow(), so save_sent_copy, sent_folder and the decrypted imap_password are present. A caller that selects named columns would silently never file.
includes/mailbox_send.php, at the end of mailboxTestResolve():
if (array_key_exists('save_sent_copy', $form)) {
$mb['save_sent_copy'] = !empty($form['save_sent_copy']) ? 1 : 0;
$mb['sent_folder'] = trim((string)($form['sent_folder'] ?? ''));
}
if (!empty($mb['save_sent_copy']) && !$typed($form['imap_password'] ?? '')
&& !($stored !== null && $same($stored['imap_server'] ?? '', $mb['imap_server'] ?? '')
&& $same($stored['imap_username'] ?? '', $mb['imap_username'] ?? '') && ($stored['imap_password'] ?? '') !== '')) {
$mb['save_sent_copy'] = 0;
$mb['sent_copy_not_tried'] = 'The copy for Sent was not tried: type the IMAP password - the saved one is only used with the IMAP server and username it was saved for.';
}Test sends with the form's values, saved or not, so the toggle and folder are tested as typed.
TRAP: the stored IMAP password only goes to the IMAP server and username it was saved for. Test already had this rule for the SMTP server (Basic IMAP client - Developer Guide, 9b). The copy signs in to the IMAP server, which that check does not look at - so with only the SMTP check, a form with the SMTP server unchanged and a different IMAP server would have sent the saved password to the new IMAP server. With the password left blank and the IMAP server or username changed, the copy is not tried, the email is still sent, and the result says to type the password.
tests/mailbox-sent-copy.phpsection 7 checks both sides.
api/tickets/test_mailbox_send.php adds the outcome to its answer, Basic IMAP only:
// GH #270: where the copy went, or why it did not (Basic IMAP with the toggle on).
$copy = null;
if (strtolower((string)($mailbox['provider'] ?? '')) === 'imap') {
if (!empty($mailbox['sent_copy_not_tried'])) {
$copy = ['folder' => null, 'error' => (string)$mailbox['sent_copy_not_tried']];
} elseif (!empty($mailbox['save_sent_copy']) && function_exists('imapSentCopyOutcome')) {
$copy = imapSentCopyOutcome();
}
}
echo json_encode(['success' => true, 'to' => $to, 'used_saved' => $usedSaved, 'sent_copy' => $copy]);A Test with the copy failing still returns success: true - the test email went. The copy's failure is also in the outbound log as sent_copy, next to the mailbox_test row.
includes/db_verify_schema.php, under target_mailboxes:
// Basic IMAP (GH #270): save a copy of each sent email to a folder over IMAP.
// Off by default - some SMTP servers file sent mail themselves. sent_folder
// NULL/blank = the folder the server marks \Sent. includes/mailbox_imap.php.
'save_sent_copy' => 'TINYINT(1) NOT NULL DEFAULT 0',
'sent_folder' => 'VARCHAR(255) NULL',database/freeitsm.sql:
-- Basic IMAP (#270): save a copy of each sent email over IMAP. Off by default;
-- sent_folder NULL = the folder the server marks \Sent.
`save_sent_copy` TINYINT(1) NOT NULL DEFAULT 0,
`sent_folder` VARCHAR(255) NULL,An upgraded install runs the new code before an administrator runs Database Verification. Both endpoints check for the column so nothing breaks in between.
api/tickets/get_mailboxes.php - defaults when it is missing:
// Save a copy of sent mail (GH #270), once Database Verification has added the columns.
$sensCol .= $conn->query("SHOW COLUMNS FROM target_mailboxes LIKE 'save_sent_copy'")->fetch() ? ' save_sent_copy, sent_folder,' : ' 0 AS save_sent_copy, NULL AS sent_folder,';api/tickets/save_mailbox.php - only written when sent and when the column exists, so an older API client that does not know the fields leaves them alone:
if (array_key_exists('save_sent_copy', $data) && $conn->query("SHOW COLUMNS FROM target_mailboxes LIKE 'save_sent_copy'")->fetch()) {
$cols['save_sent_copy'] = !empty($data['save_sent_copy']) ? 1 : 0;
$sf = trim((string)($data['sent_folder'] ?? ''));
$cols['sent_folder'] = $sf !== '' ? mb_substr($sf, 0, 255) : null;
}The send path needs no guard: before Database Verification the row has no save_sent_copy key, !empty() is false, and nothing is filed.
The toggle sits in the mailbox modal under Use the IMAP login for sending, with class provider-imap so the provider switch shows it for Basic IMAP only. The folder row has class sent-copy and not provider-imap: it answers to two things (the provider and the toggle), so one function owns it - the same pattern as the SMTP credentials row and toggleSmtpCredentials().
/** The Sent folder row: only for Basic IMAP with "Save a copy" on (GH #270). */
function toggleSentFolder() {
const on = document.getElementById('mailboxProvider').value === 'imap'
&& document.getElementById('mailboxSaveSentCopy').checked;
document.getElementById('sentFolderGroup').style.display = on ? '' : 'none';
}
/** Verify the Sent folder; blank means the server's own Sent folder, which the Test button reports. */
function verifySentFolder() {
return runFolderVerify('mailboxSentFolder', 'verifySentFolderResult', 'verifySentFolderBtn');
}toggleSentFolder() runs from the checkbox's onchange, when the modal is filled, and from the provider-change handler next to toggleSmtpCredentials(). verifySentFolder() reuses runFolderVerify(), so - like the other folder boxes - it checks the saved mailbox and asks for a folder name when the box is blank.
Saving sends the toggle only as on for Basic IMAP:
save_sent_copy: document.getElementById('mailboxProvider').value === 'imap' && document.getElementById('mailboxSaveSentCopy').checked,
sent_folder: document.getElementById('mailboxSentFolder').value.trim() || null,Test sends both with the rest of the form, and adds the copy's outcome to the result - green with the folder, or amber with the reason:
let msg = window.t(data.used_saved ? 'tickets.settings.mailbox_test.sent_saved' : 'tickets.settings.mailbox_test.sent', { to: data.to });
// GH #270: where the copy for Sent went - or, amber, why it did not.
if (data.sent_copy) {
if (data.sent_copy.error) {
msg += ' ' + data.sent_copy.error;
resultEl.style.color = '#856404';
} else {
msg += ' ' + window.t('tickets.settings.mailbox_test.copy_saved', { folder: data.sent_copy.folder || '' });
}
}
resultEl.textContent = msg;textContent, so a folder name or a server message can never become markup. The error text comes from the server in English, like the other Test errors.
php tests/mailbox-sent-copy.php
No database, no Docker, no real mailbox. The file runs itself as child processes (--fake-smtp, --fake-imap) - a tiny SMTP server that records the DATA it receives, and a tiny IMAP server that offers one of three folder lists (special: "Sent Items" marked \Sent; named: INBOX.Sent and Sent, no flags; none) and records every APPEND. Then it sends through the real imapSmtpSend().
| Section | Checks |
|---|---|
| 1. Off (the default) | sent; no copy attempted |
| 2. On, folder blank | one copy, to "Sent Items" (the \Sent one), \Seen, byte for byte the email SMTP received with the dot-stuffing undone, with To, Cc and Subject; the outcome names the folder |
| 3. Internal notifications |
$saveCopy = false sends without a copy even with the toggle on; template_email.php really passes false
|
| 4. Named folder |
inbox/sent is saved to INBOX.Sent; blank with no flag finds Sent by name |
| 5. Copy fails, send counts | no Sent folder (the reason says to name one); a missing folder (the server's TRYCREATE, naming it); IMAP server down; the sent_copy route exists |
| 6. Failed send | SMTP refused: the send throws, nothing appended, outcome null
|
| 7. Test button | the form's choice is tested; IMAP server changed + no password = not tried, with the reason; with the password typed = tried |
Start the two test servers - GreenMail now also needs 3025, its SMTP port, for the round trips:
docker run -d --name freeitsm-imaptest-dovecot -e USER_PASSWORD='{PLAIN}pass' \
-p 31143:31143 -p 31993:31993 dovecot/dovecot:latest
docker run -d --name freeitsm-imaptest-greenmail \
-e GREENMAIL_OPTS='-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled' \
-p 3143:3143 -p 3993:3993 -p 3025:3025 greenmail/standalone:2.1.3
php tests/imap-client-live.php
docker rm -f freeitsm-imaptest-dovecot freeitsm-imaptest-greenmail
The section save a copy of sent mail, per server, over SSL: the Sent folder is found with the setting blank (Dovecot by SPECIAL-USE, GreenMail by name - the run prints which); imapSaveSentCopy() saves a 12 KB message with an encoded subject and a dot-led line, read back byte for byte and marked \Seen; a folder typed as sent goes to Sent; a missing folder returns an error naming it and never throws.
Then on GreenMail, the round trip: imapSmtpSend() with the toggle on sends a reply with a PDF attachment and the subject Re: [#77] Printer β done β; Sent then holds exactly one message with that subject and the attachment; an internal-notification send ($saveCopy = false) adds nothing.
Last run (Nov 2026): 104 passed, 0 failed.
-
Append
$raw, never the dot-stuffed$message. Stuffing is SMTP's; anAPPENDof the stuffed text doubles every leading dot in Sent. -
The copy must never throw. The email has already gone. Anything new inside
imapSaveSentCopy()stays inside itstry. -
A new internal-only send passes
$saveCopy = false- follow the Graph branch'ssaveToSentItems. -
Load the whole mailbox row (
SELECT *) on any new send path, or the toggle is never seen. - Test: the stored IMAP password only goes to the IMAP server and username it was saved for - the SMTP-server check does not cover the copy (section 7).
- The folder is never created. A missing folder is a logged failure with the server's words, not a new folder.
- Off by default, deliberately. Servers that file sent mail themselves would show two copies; there is no reliable way to detect them, so it stays a per-mailbox choice.
- Basic IMAP mailboxes - Save a copy of sent mail - the user guide
- Basic IMAP client - Developer Guide Β· Testing
-
Email send log - Developer Guide - routes, including
sent_copy - Response to #262
- RFCs: 3501 Β§6.3.11 (APPEND), 6154 (SPECIAL-USE), 5321 Β§4.5.2 (dot-stuffing)
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- π PDF exports moved to PDFKit (#254)
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- β³ π Switching modules off
- β³ π οΈ Module availability β Developer Guide
- β³ π Module availability β design (#217)
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
-
π¦ Microsoft Entra ID
- β³ π₯ Importing people and groups
- β³ π» Intune: assets to their primary user
- β³ π’ Organisation chart
- β³ π οΈ Entra directory sync β Developer Guide
- β³ π οΈ Entra import - review (#235)
- β³ π οΈ Entra follow-ups - review (#237-#240)
- β³ π οΈ Intune primary user - review (#236)
- β³ π οΈ Organisation chart - reviews (#242, #265)
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π οΈ Empty Procedures fold - review (#247)
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Live updates
- β³ π οΈ Watching a ticket - Developer Guide (1 of 2)
- β³ π οΈ CSAT delay and reminder - Developer Guide
- β³ π οΈ Ticket checklist step types - Developer Guide (1 of 2)
- β³ π οΈ Right-click Attach checklist - Developer Guide
- β³ π οΈ Checklists in the REST API - Developer Guide
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Floating windows & the file viewer β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Simpler ticket and task forms - design (#218)
- β³ ποΈ The ticket workspace
- β³ π Forms on tickets
- β³ π The ticket table
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π§Ύ A type for every new ticket
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ π Ticket dashboard β Developer Guide
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
-
β³ Basic IMAP mailboxes
- β³ π Basic IMAP without the PHP extension (#257)
- β³ π οΈ Basic IMAP client β Developer Guide
- β³ π§ͺ Basic IMAP client β Testing and verifying
- β³ π οΈ Basic IMAP - Sent copy - Developer Guide (#270)
- β³ π οΈ Verifying a mail folder β Developer Guide
- β³ π οΈ Mailbox providers β Developer Guide
- β³ π οΈ How Gmail mail collection works β Developer Guide
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
-
Tasks
- β³ π οΈ Task board and subtasks β Developer Guide
- β³ π§βπ€βπ§ People on a task ("Involved")
- β³ π Repeating tasks
- β³ π’ Moving a task between companies
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Linked configuration item
- β³ π Connected assets
- β³ π Asset review
-
β³ π·οΈ QR asset labels
- β³ π οΈ QR asset labels β Developer Guide
- β³ π Asset label designer - design (#229)
- β³ π· Asset scanning β Developer Guide
- β³ π οΈ Asset tag numbering β Developer Guide
- β³ π οΈ Asset reconciliation β Developer Guide
- β³ π οΈ Reconciliation, tags + labels - review (#164)
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ πͺ Hyper-V and Failover Clusters
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Starters & leavers
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
-
CMDB
- β³ π οΈ CMDB β Developer Guide
- β³ π οΈ Impact analysis β Developer Guide
- β³ π οΈ Data quality β Developer Guide
- β³ π Linked assets
- β³ π Linking an asset to its CI - design (#201)
- β³ π Discovery and redundancy-aware impact - design (#199)
- β³ π οΈ Relationship wording - review (#297)
- Network Mapper
-
Workflows
- β³ π οΈ Workflow engine β Developer Guide
- β³ π οΈ Workflows for tasks β Developer Guide
- β³ π οΈ Workflow notes on a ticket β Developer Guide
- β³ π οΈ Workflow category routing β Developer Guide
- β³ β° Time-Based Triggers
- β³ πͺ Webhooks
- β³ π¬ Webhook Message Formats
- β³ π HTTPS Certificates & CA Bundles
- β³ π οΈ SSL Verification β the Global Setting
- β³ π Workflow & Webhook Pitfalls
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
-
System
- β³ π οΈ Preferences β Developer Guide
- β³ π§Ή Maintenance
- β³ π Finding people in the Analysts list
- β³ πͺ When an analyst leaves
- β³ π οΈ System β Branding β Developer Guide
- β³ π©Ί Page health (D019) β Developer Guide
- β³ β Feature Bingo
- β³ π·οΈ Cost centres
- β³ π· Photo Album
- β³ Demo Data
- β³ π οΈ Demo Data β Developer Guide
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ One company supporting another
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π #301 CSAT delay was never applied
- β³ π #281 Comments imported from Jira never arrived
- β³ π #288 A change failed to open with Enforce approvals on
- β³ π Long non-Latin articles never got an embedding
- β³ π« #280 Email lookups scanned the whole table
- β³ β #266 Dragging a task told nobody
- β³ π #255 Article previews showed HTML codes
- β³ π #252 A link to a Knowledge article did not open
- β³ π #254 PDFs could not print Polish
- β³ π #250 Clicking a folder left the open article on screen
- β³ π« #260 The dashboard cog did nothing
- β³ π« #248 A department rule never matched on four routes
- β³ π A directory account could be matched to someone else
- β³ π« #244 Reply went to the assigned analyst
- β³ π A lookup answer could name any record
- β³ π« #227 A mailbox could not be deleted
- β³ π« #226 A picture on a note could not be closed
- β³ π» #222 The Assets page never loaded in 4.3.0
- β³ βοΈ #214 A "contains" condition did not match capitals
- β³ βοΈ #216 A workflow email from a chosen mailbox always failed
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96