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.
- A
Labelbelongs to the user, not to an account. ALabelBindingmaterialises it on one account and carries what it is there: an IMAP folder (mailbox), a Gmail label, an Exchange folder or an Outlook category. A binding with none of these is a tag on that account: putting a label on an account's mail writes one (LabelResolver::usedAsTagOn()), so no binding means the label has not been used there. - A
Mailboxrow is an IMAP folder, keyed by its raw path. A message has onemailboxand any number of labels; the label bound to its mailbox is its location label. - In the sidebar an IMAP folder appears under its account, as its location label. Moving to it
moves mail; a label made in plMail on an IMAP account is a tag and moves nothing
(
LabelChangePropagator).
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 separator is the server's. It comes from LIST and is stored on each
Mailboxrow; a child path is its parent's raw path, that parent's separator, and the new name. - The top level is the personal namespace (RFC 2342). New folder asks
NAMESPACE; on a Courier-style server the answer isINBOX., and a top-level folder becomesINBOX.Name. A server that does not answer falls back to the evidence of the folders the account already has. - Modified UTF-7, exactly once (RFC 3501 §5.1.3).
Mailbox::$fullPathis stored as it travels, already encoded, and goes back to the server byte for byte. Only a name a person typed is encoded, inImapFolderName. The folder commands go through the connection, not through webklex'sClient::createFolder(): that one encodes whatever it is given, so an encoded path came out encoded twice ("K&APY-ln" → "K&-APY-ln"), follows the CREATE with a full LIST, and with a plain EXPUNGE unless told not to. - The separator is refused in a name, not escaped. IMAP has no escape for it: "v1.2" on a
.server is a folder "2" inside a folder "v1". The LIST wildcards*and%, control characters and a top-level "INBOX" are refused as well, all before the server is asked.
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()).
- Create —
CREATE, thenSUBSCRIBE, then theMailboxrow and its label as a sync would make them. "Already exists" counts as success. Any other refusal isImapFolderRefusedExceptionwith the server's words, shown in the form; the background callers ofImapFolderProvisioner(archive, trash, move) still get null and a log line, because a Messenger retry would only ask again. - Rename —
RENAMEwith raw paths both ways, in the same place. The server renames the folders inside along with it (RFC 3501 §6.3.5), so their rows are rewritten at once rather than met by the next sync as new folders. The subscription is moved. The server may give the renamed folders a new UIDVALIDITY, so aSyncAccountMessagefollows, andVanishedMessageReconcilerre-reads a folder whose UIDVALIDITY changed. - Delete — the rule
RemoteLabelRemoveralready keeps: empty here, empty on the server (STATUS MESSAGES), and counted here again after the round trip, becausemessage.mailbox_idcascades. A folder with folders inside it is refused too: what DELETE does to them depends on the server (§6.3.4). Its label goes with it only when nothing else is left on it. - Never touched: the inbox and every special-use folder (
ImapFolderManager::isProtected()). Other clients and plMail's own sending, archiving and spam handling find them by name or role. Nothing in step 1 moves mail, and no folder is created, renamed or deleted except by one of these three actions or by the moves that already provisioned one.
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.
- Capability. A folder keeps new keywords when its
SELECTanswer carries\*inPERMANENTFLAGS(RFC 9051 §7.1).ImapPermanentFlagsasks — SELECT, because Dovecot answers a read-only EXAMINE with an empty list — and records it onMailbox::$keywordsAllowed(null until asked). Asked for every fetched folder when the switch goes on, and again before every write. - Names.
ImapKeywordmaps a label's full name to an atom and back, exactly: printable ASCII an atom can hold passes through ("Work", "Invoices/2025", what other clients write), anything else is a modified-UTF-7-style&…-run ("Follow up" →Follow&ACA-up). Keywords starting with$, and the few older clients write without it (Junk,NonJunk,Forwarded, …), never decode to a label; a label spelt like one is encoded so the two cannot collide. - Writing.
LabelChangePropagator::dispatchKeywords()queuesApplyImapKeywordsMessagefor a label that is a tag on that account — not a system label, not a folder there — with the address of every server copy, taken fromMessageCopies::of(). The handler SELECTs each folder and sendsUID STORE ±FLAGS.SILENTthrough the connection (webklex'ssetFlag()would prefix a backslash), then updates the row's flag mirror. It does not use the echo guard (Message::$flagsTouchedAt), which is one timestamp shared with star and read: reading is change-based on the mirror, and the mirror only moves when the handler has written the keyword, so a pass before the job sees no change. - Reading. At import, a message wears the labels its keywords stand for (
MessageSyncer). After that, both inbound flag doors — the folder listing (ImapFlagReconciler) and the line IDLE pushes (ApplyRemoteFlagsMessageHandler) — callKeywordLabelFollower, which reads a change: the keywords in the row's mirror before the update against after it. Never "the server's set is the truth", which would strip every label given while the switch was off. A new keyword makes a plain label with no binding (KeywordLabels). - Capitals (#487). Keywords are atoms and compare case-insensitively, so
KeywordLabelslooks a name up withLabelRepository::findNameConflict()(exact spelling first, then any) rather than the exactfindOneChildByName()that folders need. The follower's before/after diff is keyed by the folded name too, orPaperless→paperlesswould read as the label taken off and put back on. A keyword never gives a system label (archiveis not Archive). - Import when switched on (#487). Keywords already in the mirror when the switch goes on are no
change, so the follower would never see them.
AccountFoldersController::keywords()queuesImportImapKeywordsMessage(maintenance), whose handler walks the account's foldered messages a page at a time and runsKeywordLabelFollower::importExisting()— the follower with an empty before, so it only adds. It reads the switch again and stops when it is off. - The indicator.
LabelPlacessays per IMAP account whether a label is a folder there, a keyword, or plMail-only and why; the label dialog shows it.
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.
- Mirror.
Message::$graphCategories(JSON, null until known) is what Exchange last held: set at import from the payload, and after every successful PATCH to the array that was sent. It is the "before" for reading a change, the way the flag mirror is for keywords. - Reading.
GraphApiClient::DELTA_SELECTnow carriescategories. A delta link remembers the$selectit began with, so the field only arrives on chains started after that.GraphApiSyncer::followCategories()runs for an existing message in a delta page when the account follows categories: the server's list against the mirror; added names put the label on (an unknown name makes a plain label,GraphFolderResolver::categoryLabel()), removed names take off labels that are categories there, never a folder-backed one. A null mirror only learns. Never "the server's list is the truth", which would strip every label plMail gave that Exchange never had. - Echo. The PATCH handler moves the mirror to what it sent, so the delta that reports plMail's own write sees no change. No timestamp guard.
- The switch.
Account::$followsOutlookCategories(settings keylabels.outlook_categories), off by default, an icon toggle on a Microsoft account's row (AccountCategoriesController, the push control's shape). Turning it on emptiesgraphDeltaLinks, like migrationVersion20261014110000did forflag, so every folder's chain restarts withcategories; existing mail is recognised bygraphIdand not fetched again. Writing was never switched and is unchanged. - Rename. Unchanged in intent: the master category gets the new name and every message plMail
holds with the label is re-pushed (
retagMessages()), as Outlook's own rename dialog offers. Graph documentsoutlookCategory.displayNameas fixed after creation, so a 400/405 on the PATCH now falls back toreplaceCategory(): take or create the category under the new name, delete the old one, re-tag. Deleting a master category leaves its name on each message as text — the same end state as a rename — so the fallback touches no message the rename would not. - Delete. Unchanged: the master category is deleted; the name stays on messages as uncoloured text until the next push for that message derives the array without it.
- The indicator.
LabelPlacesnow lists Microsoft accounts:folder,category(not followed) orcategory_followed.
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.
- 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)".
- 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.
- 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.
- 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 …".
- 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.
- Follow a removal. In Outlook remove "Step4 C" from that message. Sync: the label is off the conversation in plMail.
- No echo. In plMail put "Step4 A" on another conversation, then sync twice: it stays on, and Outlook shows it.
- 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:showis empty. - 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.
- 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.