Refuse a label name that differs from an existing one only in capitals

"work" could be created beside "Work". The sibling check was an exact
match, and the strictest provider's rule is not: Gmail holds the two to
be one label and answers the second with "409: Label name exists or
conflicts" — in ApplyGmailLabelsHandler, in a worker, after the request
that made the label had answered "created". So the label existed here,
could be put on mail here, and never reached Gmail, with an error in
the log and nothing on any screen. It was found from the Android app,
which had been told the create worked.

The check in front of a person typing a name now ignores case, in
Mailbox/set (create and rename) and in the browser's new-label form.
It is a second repository method rather than a change to
findOneChildByName(), and the two must stay apart: that one is
find-or-create for mail arriving from a provider, where an exact match
is the only safe one — a case-sensitive IMAP server may really hold
`work` and `Work` as two folders, and folding them would file two
folders' mail under one label.

Mailbox/set's description is written for a person, because a client
shows it to one. When capitals are the whole difference it names the
existing label as it is spelled — somebody who typed "work" and is told
"work already exists" goes looking for a label that is not there — and
it carries `properties: ["name"]`.

Roles are included: a custom "inbox" beside the Inbox is the same
collision with a name the provider reserves. A label may still
recapitalise itself.

Nothing is done about pairs that already exist, and the handler still
logs the 409 for them: adopting Gmail's label on a conflict would bind
two labels here to one there. The browser's rename form has no
duplicate check at all, before or after this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-08 11:39:40 +02:00
1 parent 3fae44956b
commit d3292e3a6d
9 files changed
+168 -7

No files matched your search

+9
View File
@@ -8,6 +8,15 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
**A label name that differs from an existing one only in capitals is refused.** "work" could be
created beside "Work". Gmail treats the two as one label and rejected the second, but only later, in
the background: the new label existed in plMail, could be put on mail, and never reached Gmail, with
an error in the log and nothing on screen. plMail now refuses it when you create or rename the
label, in the browser and from the apps, and says which label is in the way.
- **If you already have such a pair**, nothing is changed for you. Delete or rename the newer one;
until you do, putting it on Gmail mail keeps failing in the background.
**"Move to" is available to apps, not just the browser.** The Android and iOS apps talk to plMail
over JMAP, which had no way to ask for a move: an app could only add one label and remove another
itself, which files mail in two places on a plain IMAP account and cannot take mail out of Trash or
+3 -1
View File
@@ -65,7 +65,9 @@ final class LabelController extends AbstractController
if (true === $form->isSubmitted() && true === $form->isValid()) {
$label->usr = $this->getUser();
$duplicate = $this->labelRepository->findOneChildByName(
// Capitals ignored — see the repository method for whose rule
// that is. The same check Mailbox/set makes.
$duplicate = $this->labelRepository->findOneChildByNameIgnoringCase(
$this->getUser(),
$label->parent,
(string) $label->name,
+28 -3
View File
@@ -469,11 +469,20 @@ final class MailboxSetMethod implements JmapMethod
/**
* Siblings must have distinct names — the same rule LabelResolver relies on
* when it does find-or-create by (parent, name).
* when it does find-or-create by (parent, name) — and names that differ
* only in capitals are not distinct. See
* LabelRepository::findOneChildByNameIgnoringCase() for whose rule that is.
*
* The description is written for a person, because a client shows it to
* one: there is no error type for "taken", `invalidProperties` is what the
* spec offers, and the sentence is the only part that says which label is
* in the way. When the capitals are the whole difference it names the
* existing label as it is actually spelled — somebody who typed "work" and
* is told "work already exists" goes looking for a label that is not there.
*/
private function assertNameFree(Account $account, ?Label $parent, string $name, ?Label $ignore = null): void
{
$existing = $this->labelRepository->findOneChildByName($account->usr, $parent, $name);
$existing = $this->labelRepository->findOneChildByNameIgnoringCase($account->usr, $parent, $name);
if (null === $existing) {
return;
@@ -483,7 +492,23 @@ final class MailboxSetMethod implements JmapMethod
return;
}
throw new MethodException('invalidProperties', sprintf('A mailbox named "%s" already exists here.', $name));
if ($existing->name === $name) {
throw new MethodException(
'invalidProperties',
sprintf('A label named "%s" already exists here.', $name),
['properties' => ['name']],
);
}
throw new MethodException(
'invalidProperties',
sprintf(
'You already have a label named "%s". Names that differ only in capitals count as the same, so "%s" is taken.',
(string) $existing->name,
$name,
),
['properties' => ['name']],
);
}
/**
+47
View File
@@ -85,6 +85,53 @@ class LabelRepository extends ServiceEntityRepository
]);
}
/**
* The sibling a NEW name would collide with, capitals ignored.
*
* Not the same question findOneChildByName() answers, and the two must not
* be merged. That one is find-or-create for mail arriving from a provider,
* where an exact match is the only safe one: a case-sensitive IMAP server
* may really hold `work` and `Work` as two folders, and folding them would
* file two folders' mail under one label. This one is the check in front
* of a person typing a name, and there the rule is the strictest
* provider's — Gmail treats `work` and `Work` as one label and answers a
* second with "409: Label name exists or conflicts".
*
* That refusal arrives in ApplyGmailLabelsHandler, in a worker, long after
* the request that made the label has answered "created" — so the label
* existed here, could be put on mail here, and never reached Gmail, with
* nothing on any screen to say so. Refusing at the door is the only place
* the person who typed it can be told.
*
* Roles are included on purpose: a custom "inbox" beside the Inbox is the
* same collision with a name the provider reserves.
*
* LOWER() on both sides rather than ILIKE, so `%` and `_` in a name are
* characters and not wildcards.
*/
public function findOneChildByNameIgnoringCase(UserInterface $user, ?Label $parent, string $name): ?Label
{
$builder = $this->createQueryBuilder('label')
->where('label.usr = :usr')
->andWhere('LOWER(label.name) = :name')
->setParameter('usr', $user)
->setParameter('name', mb_strtolower($name))
->orderBy('label.id', 'ASC')
->setMaxResults(1);
if (null === $parent) {
$builder->andWhere('label.parent IS NULL');
} else {
$builder->andWhere('label.parent = :parent')->setParameter('parent', $parent);
}
// The exact spelling first, so a caller that reports which label is in
// the way names the one that matches character for character where
// there is one.
return $this->findOneChildByName($user, $parent, $name)
?? $builder->getQuery()->getOneOrNullResult();
}
/**
* The labels a label may be nested under, for the parent picker.
*
@@ -117,6 +117,42 @@ final class MailboxSetStructureTest extends JmapTestCase
self::assertSame('invalidProperties', ((array) $result['notCreated'])['m2']['type']);
}
/**
* Capitals do not make a different name. Gmail holds `work` and `Work` to
* be one label and refuses the second — in a worker, after this method has
* answered "created" — so the label would exist here and never there. The
* refusal has to come from here, and it has to name the label that is
* actually in the way: somebody who typed "work" and is told "work exists"
* goes looking for a label that is not there.
*/
public function testANameThatDiffersOnlyInCapitalsIsTaken(): void
{
$this->createMailbox('Work');
$result = $this->handle(['create' => ['m1' => ['name' => 'work']]]);
$error = ((array) $result['notCreated'])['m1'];
self::assertSame('invalidProperties', $error['type']);
self::assertSame(['name'], $error['properties']);
self::assertStringContainsString('"Work"', $error['description']);
self::assertStringContainsString('capitals', $error['description']);
}
/** And a rename cannot arrive at one either — but may recapitalise itself. */
public function testARenameCannotLandOnANameThatDiffersOnlyInCapitals(): void
{
$this->createMailbox('Work');
$other = $this->createMailbox('Receipts');
$refused = $this->handle(['update' => [$other => ['name' => 'WORK']]]);
self::assertSame('invalidProperties', ((array) $refused['notUpdated'])[$other]['type']);
$recased = $this->handle(['update' => [$other => ['name' => 'receipts']]]);
self::assertArrayHasKey($other, (array) $recased['updated']);
}
/** The same name under a different parent is a different mailbox. */
public function testTheSameNameIsFreeUnderADifferentParent(): void
{
@@ -109,6 +109,48 @@ final class LabelRepositoryTest extends KernelTestCase
self::assertNull($this->repository->findOneChildByName($this->user, null, 'Invoices'));
}
// ── a name somebody is typing ────────────────────────────────────────────
/**
* The two lookups answer different questions and must keep doing so:
* find-or-create is exact, because a case-sensitive IMAP server may hold
* `work` and `Work` as two folders; the check in front of a person is not.
*/
public function testCapitalsAreIgnoredOnlyByTheCheckForANewName(): void
{
$work = $this->label('Work');
self::assertNull($this->repository->findOneChildByName($this->user, null, 'work'));
self::assertSame($work, $this->repository->findOneChildByNameIgnoringCase($this->user, null, 'work'));
self::assertSame($work, $this->repository->findOneChildByNameIgnoringCase($this->user, null, 'WORK'));
}
/** Umlauts fold too: `ärzte` and `Ärzte` are one name to a person and to Gmail. */
public function testIgnoringCaseCoversLettersOutsideAscii(): void
{
$doctors = $this->label('Ärzte');
self::assertSame($doctors, $this->repository->findOneChildByNameIgnoringCase($this->user, null, 'ärzte'));
}
public function testIgnoringCaseStillKeepsToTheParent(): void
{
$work = $this->label('Work');
$this->label('Invoices', parent: $work);
self::assertNull($this->repository->findOneChildByNameIgnoringCase($this->user, null, 'invoices'));
self::assertNotNull($this->repository->findOneChildByNameIgnoringCase($this->user, $work, 'invoices'));
}
/** `%` and `_` are characters in a name, not wildcards. */
public function testAWildcardCharacterInANameMatchesOnlyItself(): void
{
$this->label('Work');
self::assertNull($this->repository->findOneChildByNameIgnoringCase($this->user, null, 'w_rk'));
self::assertNull($this->repository->findOneChildByNameIgnoringCase($this->user, null, 'w%'));
}
// ── the parent picker ────────────────────────────────────────────────────
/** A system mailbox is not somewhere a user gets to file things under. */
+1 -1
View File
@@ -2104,7 +2104,7 @@ label:
delete_confirm_children: '"%name%" und die %count% untergeordneten Label(s) löschen? Sie verschwinden aus allen Konten. Nachrichten werden nicht gelöscht.'
deleted: Label gelöscht.
error:
duplicate: Hier gibt es bereits ein Label mit diesem Namen.
duplicate: Hier gibt es bereits ein Label mit diesem Namen. Groß- und Kleinschreibung macht daraus keinen anderen.
form:
color: Farbe
delete: Label löschen
+1 -1
View File
@@ -2104,7 +2104,7 @@ label:
delete_confirm_children: 'Delete "%name%" and its %count% nested label(s)? They go from every account. Messages are not deleted.'
deleted: Label deleted.
error:
duplicate: A label with this name already exists here.
duplicate: A label with this name already exists here. Capitals do not make it a different name.
form:
color: Color
delete: Delete label
+1 -1
View File
@@ -1984,7 +1984,7 @@ label:
delete_confirm_children: 'Strike "%name%" and the %count% flag(s) beneath it? They come down on every ship in the fleet. No yarns be thrown overboard.'
deleted: Flag struck.
error:
duplicate: A flag by that name already flies here.
duplicate: A flag by that name already flies here. Shoutin it in capitals dont make it a new one.
form:
color: Colour
delete: Strike this flag