Skip to content

Basic IMAP Sent Copy Developer Guide

Ed Mozley edited this page Nov 11, 2026 · 1 revision

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.


1. Why it exists

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.


2. The files

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)

3. The flow, from a reply to the APPEND

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', ...)

3.1 imapSmtpSend() keeps the message before dot-stuffing

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.

3.2 ImapClient::append()

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, so Gesendete Objekte or Γ‰lements envoyΓ©s work) and imapString() (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.


4. Choosing the folder

4.1 Blank: imapFindSentFolder()

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;
}
  1. SPECIAL-USE first (RFC 6154): the folder LIST marks \Sent. Dovecot, Gmail, Exchange and most hosts mark one, whatever it is called in the user's language.
  2. Then common names, case-insensitive. GreenMail, for example, marks nothing - the live test prints which way the folder was found on each server.
  3. Neither: null, and imapSaveSentCopy() fails with "the server marks no folder as Sent - name the folder in the mailbox settings".

4.2 Typed: matched to the server's spelling

                // 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.


5. Best effort: imapSaveSentCopy() never throws

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, APPEND refused) is caught.
  • Where a failure goes: error_log, and a Failed row in the outbound email log under route sent_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 - APPEND goes 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.

5.1 imapSentCopyOutcome() - what happened, for the Test button

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.


6. Which sends are filed

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 Items

The 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.


7. The Test button

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.php section 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.


8. Schema and the column guards

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.


9. The JavaScript (tickets/settings/index.php)

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.


10. Tests

tests/mailbox-sent-copy.php - 23 checks, nothing needed

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

tests/imap-client-live.php - real servers in Docker (104 checks in all)

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.


11. Traps

  • Append $raw, never the dot-stuffed $message. Stuffing is SMTP's; an APPEND of 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 its try.
  • A new internal-only send passes $saveCopy = false - follow the Graph branch's saveToSentItems.
  • 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.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally