plMail

Folders and labels

How plMail tells a place from a tag, what the code does with each on a plain IMAP account, and the rules the folder actions keep. The design was agreed in #221; this page is the part of it that is built. For using it, see Folders and Labels.

The model

A folder is a place. Every message is in exactly one folder of its account. The folders are the account's real tree on the server, mirrored as it is, however deep and however oddly named. Moving mail means moving it between folders, and making, renaming and deleting a folder are actions on the folder.

A label is a tag. A message can carry any number of them, and putting one on or taking one off never moves mail.

The principle behind both: plMail adapts to the organisation the person already has and never quietly reshapes it. Somebody who spent a decade building a folder tree should find it in plMail untouched; turning folders into labels, or labels into folders, is something they ask for, not a side effect.

The protocols split the same way. IMAP has mailboxes and keywords (flags), Outlook has folders and categories, and JMAP (RFC 8621) has mailboxIds and keywords — which matters because plMail serves JMAP itself. Only Gmail has labels and nothing else.

Where the code is today

The data model separates the two, and since step 3 so does the interface.

Step 1: folders as folders (built)

The sync mirrors, and stops reshaping

MailboxSyncer used to re-derive every folder's label from its path on every run (segmentsFromImapPath() → customChain() → bindMailbox()). Two things followed. A label renamed in plMail was taken back by the next sync, which found-or-created a label under the old name and moved the folder to it. And two folders whose paths differ only by the INBOX namespace, Work and INBOX.Work, resolved to the same label "Work"; since a folder feeds exactly one label and a label one folder per account, they took it from each other on alternate syncs.

LabelResolver::linkFolder() is now the one rule, used by the sync and by ImapFolderProvisioner:

Folder Label
Special use (RFC 6154 attribute, or a known name) Its system label, always
New, never bound A chain read off its path, INBOX dropped and modified UTF-7 decoded
New, and that chain already feeds another folder of the account The chain with its INBOX segment kept ("INBOX › Work")
Already bound, path unchanged Left as it is
Moved (renamed by plMail or recognised as renamed by the sync), same parent, label not shared with another account, new name free The same label, renamed
Moved otherwise A chain read off the new path, as for a new folder

The one exception to "left as it is": a label whose name is still the raw modified UTF-7 of its folder ("B&APw-cher"), which syncs before names were decoded left behind. No data migration was needed; existing bindings are kept.

Paths, separators and namespaces

The actions

ImapFolderManager, behind the folder list (AccountFoldersController). The list opens from Settings → Accounts → Folders and, for a plain IMAP account, from the ⋯ menu on the account's row in the sidebar (New folder…, Folders…). The account's folder list in the sidebar ends with + New folder, and a pencil on its own folder rows opens a rename. An action that works answers with a turbo-stream — the app's toast, and both copies of the sidebar drawn again, so the folder is under its account without a reload (_folder_done.stream.html.twig); one that is refused stays in the dialog with the reason. In the LABELS section, where a folder is listed at all (a tag elsewhere, or User::$foldersInLabels), a label that is a folder on a plain IMAP account wears a folder instead of a dot (SidebarCounts::folderAccountsOf()).

None of the folder actions expunges anything.

Step 2: labels as IMAP keywords (built)

A label is a tag, and IMAP's tag is the keyword: a flag of the client's own choosing, kept per message copy like \Seen. Off by default per account (Account::$labelsAsKeywords, settings key labels.imap_keywords); off, a label on IMAP mail stays in plMail, as before.

The seam for #415

#415 splits Message into the email and its locations (MessageLocation(message, mailbox, imap_uid)), so one email can be in several folders. Keywords live on each copy, so the keyword code never reads $message->mailbox / imapUid: it asks MessageCopies::of(), which today answers the row's one address and after #415 answers every location. The read side works per folder listing and is unchanged by it; what #415 has to decide is what a label means when two copies' keywords disagree (its decision 2).

Step 4: labels as Outlook categories (built)

A label on a Microsoft account was already an Outlook category before #221: GraphLabelPolicy makes a label a folder only when it came from one (a role or a graphFolderId), ApplyGraphChangesHandler PATCHes each message's whole categories array derived from its labels and creates master categories on demand, GraphCategorySyncer imports the master list as labels, GraphMessageBuilder maps categories to labels at import, and ApplyLabelStructureHandler / RemoteLabelRemover rename and delete the master category. The gap was the way back: the delta query did not select categories, so a category put on or taken off in Outlook after import was never seen.

What stays for step 3: putting a folder-backed label on a Graph conversation still moves it (that label IS the folder there); the separate Move to / Assign label picker removes the ambiguity.

Not verified against Exchange

Everything above is tested with mocked Graph HTTP only. Not known: that a delta page reports categories for a message whose only change was a category (Graph says a delta reports any property change of the selected set); whether a push notification follows a category-only change; whether Exchange accepts or refuses a PATCH of displayName; how long re-listing a large mailbox takes after the switch goes on.

Test guide for the maintainer (Outlook)

On this branch, with migrations run and a Microsoft account connected. "Sync" below means Sync now on the account, or waiting for the next scheduled run.

  1. Off by default. Settings → Accounts. The Microsoft account's row has a grey tags icon; its tooltip starts "Follow Outlook categories — Off". Open any label's dialog (Labels → a label → edit): "Where this label is kept" says "An Outlook category on … (changes made in Outlook are not followed)".
  2. Writing, unchanged. In plMail put a new label "Step4 A" on a conversation. In Outlook on the web the messages carry the category "Step4 A", and it is in the category list. Sync: the label stays on in plMail.
  3. Off ignores Outlook. In Outlook, add the category "Step4 B" to a message plMail already has, and remove "Step4 A" from another. Sync: nothing changes in plMail.
  4. Turn it on. Click the tags icon: it turns green, tooltip "On". The account syncs every folder once more; no conversation appears twice. The label dialog now says "An Outlook category on …".
  5. Follow a new category. In Outlook add the category "Step4 C" (new to plMail) to a message. Sync: the conversation has the label "Step4 C" in plMail, and the label exists in the sidebar.
  6. Follow a removal. In Outlook remove "Step4 C" from that message. Sync: the label is off the conversation in plMail.
  7. No echo. In plMail put "Step4 A" on another conversation, then sync twice: it stays on, and Outlook shows it.
  8. Rename. In plMail rename "Step4 A" to "Step4 Renamed". In Outlook the category list has "Step4 Renamed" and not "Step4 A", and the messages plMail holds carry the new name. Please note whether the old category kept its colour (PATCH accepted) or the new one was made fresh in the label's colour (fallback), and that php bin/console messenger:failed:show is empty.
  9. Delete. Delete the label "Step4 Renamed" in plMail. It leaves Outlook's category list; the messages show the name without a colour. Sync: the label does not come back.
  10. Turn it off. Click the icon: grey again. Add or remove a category in Outlook; sync: nothing changes in plMail.

With Instant delivery on, steps 5 and 6 should land without a manual sync; whether they do is one of the unverified points above.

Step 3: Move to and Label as, apart (built)

Whether a label is a place or a tag is a question about one account, and LabelKinds answers it:

Account A place A tag
Plain IMAP a label with a mailbox there every other label (a keyword, or plMail's alone)
Microsoft a label with a graphFolderId there every other label (a category)
Gmail none, unless Account::$gmailMoveOffersLabels every label
JMAP every label (a mailbox; mail can sit in several, and a move replaces them) every label
POP3 none every label

Which labels are folders where comes from SidebarCounts::isFolderOn(), and which are in use as a tag where from isTagInUseOn(). Both read every binding of the user's in the one query the sidebar's folder marks already make, so the pickers on every list page cost nothing per label (ThreadListQueryBudgetTest). It reads the signed-in user, so it is for requests; a worker asks the binding itself, as MoveToService and the rule executor do.

The pickers. Move to offers the places (move_labels()), Label as the tags (tag_labels()): for the conversation's own account in the reading pane, and in the list, whose selection can span accounts, what is that kind on any of them. There a label that is a folder on one account is offered as a tag only where it is in use as one, the same rule as LABELS.

The move. MoveToService decides per account. Where the target is a folder there, ThreadStatusUpdater::move() takes the source label off and LabelChangePropagator::moveCopies() moves one copy on the server: the copy in the folder being looked at (viewedFolder(), also what a drop passes), or else the primary copy. Copies elsewhere — Archive, All Mail, a reply in Sent — stay where they are, and so do their labels. detachLabel(..., moveOnImap: false) takes the source label off without inferring a second move from what is left, which is what used to make a label menu able to move mail. Where the target is a tag there, the mail is tagged and leaves the inbox, Gmail's move. A move never creates a folder: a tag would become a place as a side effect.

Label as never moves. A rule's Apply label is the same, and Move to folder (RuleActionExecutor::MOVE_TO_FOLDER) is the rule's Move to, with the same per-account split. Version20261030100000 turned the rules that applied a folder label into Move to folder rules, but only where the label is a folder on every account the rule covers, because on a Gmail account the same rule only ever tagged.

The sidebar. An account's rows are its system folders and the labels that are a place there, nested as the server nests them (AccountFolderTree): on a plain IMAP account by the folders' paths, so a folder under INBOX is inside the Inbox row, and elsewhere by the label tree. A row with rows inside it collapses on its own, remembered per user under folder:<account>:<label>. LABELS holds the labels that are a tag on any account, or the parents of one; with User::$foldersInLabels it holds every label, as before step 3. A label that is a folder on one account counts as a tag on another only where it is in use as one (LabelKinds::inLabels()). Before, having no folder there was enough, so with two accounts every folder of one was listed under LABELS, and its Delete there deleted the folder.

The chips. On a message in the conversation, a folder label wears a folder and has no ×; taking a folder off is a move. A tag's × posts the same detach as unticking it in Label as, for that message, and the answer redraws that message's chips (message_labels_{id}).

Per account, in the label dialog. Under "Where this label is kept", Change per account offers Make it a folder on each plain IMAP and Microsoft account where the label is not one (LabelFolderMaker). A nested label's parent becomes a folder first. On plain IMAP the dialog asks where the folder goes (top level or inside any folder of the account) unless the label's parent is a folder there already (LabelFolderMaker::fixedPlace()); New folder asks the same, and both start on the account's last choice, a path in its settings bag (NewFolderPlace, Account::$newFolderParentPath). The top level itself is the server's answer to NAMESPACE, which on Stalwart is beside the system folders while FranklyMail's users keep theirs under the inbox: remembering the choice answers that once per account. Adding an IMAP account asks whether labels are kept as keywords (AccountType, the same Account::$labelsAsKeywords as the folders' switch). The dialog counts the mail that will move; MakeLabelFolderHandler then moves it, leaving Trash, Spam, Sent and Drafts alone, and on Outlook removes the master category.

Make it a label is the way back, offered where the folder has no folder under it. The dialog asks where the mail goes; MakeLabelTagHandler moves it, deletes the folder once the server says it is empty, and puts the label back on the moved mail, which is a tag there by then. The label is off the mail in between, because a message wearing a folder's label says it is in that folder, and on Outlook the next push would move it back. If the folder never empties the job fails, the folder stays, and the moved mail stays where it went without the label.

Later steps

All four steps agreed in #221 are built. After them: an in:folder search, and an opt-in "convert folders to labels" wizard, which stays out of #221. Moving a copy into a folder that already holds a copy of the same email is left to the server and to the next merge (ImapCopyMerger).

Things that bite

The label tree is per user, the folder trees are per account. Two accounts with a folder "Clients" share one label "Clients", and a folder inside one of them hangs under that shared label. Renaming one account's folder therefore cannot rename the shared label, and the folder gets a label of its own instead. Under each account the sidebar draws only that account's places, which is why it reads as its own tree.

A server that does not mark its system folders gets them protected only by name. Stalwart answers a plain LIST without special-use attributes, so its "Sent Items", "Deleted Items" and "Drafts" are recognised by name and its "Junk Mail" is not, and is treated as an ordinary folder.

Keywords are not rewritten when a label is renamed or deleted. The old keyword stays on the server; it is only an issue the next time another client changes it, when it reads back as a label of the old name. Outlook categories do re-tag on a rename (retagMessages()); keywords could reuse that shape later.

UIDVALIDITY from STATUS is not always the one SELECT reports. Stalwart 0.11 gives two different numbers, so MailboxSyncer::findRenameSource() cannot recognise a rename made in another client there; the folder arrives as new and the old row is removed after its grace period.

Turbo draws a 4xx into the frame the form is in. The delete button sits in its row's frame and targets the modal, so a refused delete answers 200 with the reason, not 422.