Keep Spam and Trash without working on them

A Gmail import now takes in Spam and Trash, and everything that reads
new mail for its meaning would have read those too. Message::isSetAside()
says whether a message is filed in either, and the pipeline asks it:

  ClassifyMailStep        no language-model categorisation
  the hold                not kept out of the tabs awaiting a verdict
                          that is not coming
  ExtractInsightsStep     no parcel, invoice or other cards
  ExtractEventsStep       no events read out of it; a forged invitation
                          in Spam stays out of the calendar
  ProposeEventsStep       no proposals
  contact harvesting      Spam only: a spammer is not a correspondent,
                          and somebody whose mail was deleted still is

The three read-for-meaning steps share RecentMailPolicy::recentIds(),
which leaves set-aside mail out unless asked. ReadReceiptStep asks:
pairing a receipt with its mail reads nothing, and a receipt filtered
into Spam is still that mail's.

Unchanged: storing, threading, the rule-based categoriser, the user's
filter rules, JMAP state. It applies to every provider, not only Gmail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-14 09:16:36 +02:00
1 parent cdc0ce2637
commit fded408d31
9 files changed
+233 -3

No files matched your search

+9 -1
View File
@@ -17,10 +17,18 @@ account's unless another connected account has a better claim to it; drafts arri
`googlemail.com` is read as `gmail.com`. Only chats are still left out. The import also fetches
Spam and Trash now, which it said it did and never asked Google for — the reason an import could
not reach Gmail's own total. Every Gmail account lists its mailbox once more after the upgrade to
take these in, and shows as importing until that pass is done. **Migration:** clears the record of
take these in, and shows as importing until that pass is done. A message that names nobody and that
Gmail collected from another connected account is merged into that account's copy rather than
listed twice. **Migration:** clears the record of
skipped Gmail messages except chats, and the backfill markers of Gmail
accounts. [#90](https://github.com/karatektus/pl_mail/issues/90), reported by @karamanliev.
**Spam and Trash are kept, not worked on.** Mail arriving in either — from any provider, and now
from a Gmail import — is no longer categorised by the language model, held back from the inbox
tabs, read for parcels, invoices or meetings, or offered as a calendar event, and senders of spam
no longer become contacts. A forged invitation in Spam stays out of the calendar. Read receipts
filtered into Spam are still matched to the mail they are about. No migration.
## v0.3.5 — 2026-10-13
**Browsers and password managers kept offering your logins in client-secret fields.** Opening
+39
View File
@@ -2,6 +2,7 @@
namespace App\Entity\Mail;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\ArrivalPlace;
use App\Domain\Enum\Mail\MessageCategory;
use App\Domain\Enum\Mail\MessageFlag;
@@ -651,6 +652,44 @@ class Message extends MessageModel
* Stays a method: it takes an argument and asks the collection about it, so
* there is no single piece of state here to expose as a property.
*/
/**
* Whether the message is in Spam or Trash.
*
* Mail there is kept, listed and searchable like any other, and is not
* WORKED ON: no language model categorises it, nothing reads parcels,
* invoices or meetings out of it, its senders do not become contacts and
* it is not held back from the inbox tabs it is not in. Spam is where a
* forged invitation or a fake delivery notice ends up, and Trash is mail
* the user has already finished with — paying a model to read either, or
* putting an event from it in the calendar, is the wrong way round. See
* PostIngestPipeline and the steps it runs.
*
* Read off the labels the builders file a message under before it is
* stored, the same labels the folder list draws.
*/
public function isSetAside(): bool
{
foreach ($this->labels as $label) {
if (LabelRole::Spam === $label->role || LabelRole::Trash === $label->role) {
return true;
}
}
return false;
}
/** Whether the message is in Spam. See isSetAside(). */
public function isSpam(): bool
{
foreach ($this->labels as $label) {
if (LabelRole::Spam === $label->role) {
return true;
}
}
return false;
}
public function hasLabel(Label $label): bool
{
return $this->labels->contains($label);
+8
View File
@@ -50,6 +50,14 @@ final readonly class HarvestContactsService
$rows = [];
foreach ($messages as $message) {
// A spammer is not a correspondent. Their addresses would come up
// in autocomplete and count as "somebody you write to" for the
// categoriser and the hold. Trash is harvested: mail somebody
// deleted was still mail from somebody they know.
if (true === $message->isSpam()) {
continue;
}
$rows[] = [
'fromAddress' => $message->fromAddress,
'fromName' => $message->fromName,
@@ -78,6 +78,11 @@ final readonly class ClassifyMailStep implements PostIngestStepInterface
continue;
}
// No model reads Spam or Trash. See Message::isSetAside().
if (true === $message->isSetAside()) {
continue;
}
if (true === $message->isCategoryHeld()) {
$held[] = (int) $id;
@@ -42,7 +42,10 @@ final readonly class ReadReceiptStep implements PostIngestStepInterface
public function afterCommit(PostIngestResult $result): void
{
$ids = $this->recent->recentIds($result);
// Spam and Trash included, unlike the steps that read mail for its
// meaning: this only pairs a receipt with the mail it is about, and a
// receipt filtered into Spam is still that mail's receipt.
$ids = $this->recent->recentIds($result, includeSetAside: true);
if ([] === $ids) {
return;
@@ -89,7 +89,7 @@ final readonly class RecentMailPolicy
*
* @return list<int>
*/
public function recentIds(PostIngestResult $result, ?DateTimeImmutable $now = null): array
public function recentIds(PostIngestResult $result, ?DateTimeImmutable $now = null, bool $includeSetAside = false): array
{
$now ??= new DateTimeImmutable();
$ids = [];
@@ -101,6 +101,13 @@ final readonly class RecentMailPolicy
continue;
}
// Spam and Trash are kept and not worked on — see
// Message::isSetAside(). The steps that read mail for meaning
// (insights, events, proposals) all ask here.
if (false === $includeSetAside && true === $message->isSetAside()) {
continue;
}
$ids[] = (int) $id;
}
+4
View File
@@ -136,7 +136,11 @@ final readonly class PostIngestPipeline
// The category written one line up stays — it is the rules' answer,
// and it is what the message is filed under if the model never
// replies. See ClassificationHold and Message::$categoryHeldAt.
// Spam and Trash are not held: the hold keeps a message out of the
// inbox tabs until its verdict arrives, and there is no verdict
// coming for mail no model is asked about. See Message::isSetAside().
if (true === $holding
&& false === $message->isSetAside()
&& true === $this->hold->shouldHold($message, $item->account, $correspondents, $user?->categorySorting)) {
$message->categoryHeldAt = new DateTimeImmutable();
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Domain\Enum\Mail\LabelRole;
use App\Entity\Label\Label;
use App\Entity\Mail\Contact;
use App\Entity\Mail\Message;
use App\Entity\User\User;
use App\Service\HarvestContactsService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* A spammer is not a correspondent; somebody whose mail was deleted still is.
*
* Harvested contacts feed autocomplete, and "somebody you write to" decides
* the categoriser's Primary and the classification hold. A Gmail import now
* takes in Spam, so without this every sender in it would join that list.
*/
final class HarvestSpamContactsTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->connection = self::getContainer()->get(Connection::class);
$this->connection->beginTransaction();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testSpamSendersAreNotHarvestedAndTrashSendersAre(): void
{
$user = new User();
$user->email = 'harvest-spam-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Harvest';
$user->nameLast = 'Spam';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$this->em->flush();
self::getContainer()->get(HarvestContactsService::class)->harvestMessages(
$user,
[
$this->from('winner@lottery.test', LabelRole::Spam),
$this->from('colleague@work.test', LabelRole::Trash),
],
'me@example.test',
);
$harvested = array_map(
static fn (Contact $contact): string => $contact->email,
$this->em->getRepository(Contact::class)->findBy(['usr' => $user]),
);
self::assertContains('colleague@work.test', $harvested);
self::assertNotContains('winner@lottery.test', $harvested);
}
private function from(string $address, LabelRole $role): Message
{
$label = new Label();
$label->role = $role;
$message = new Message();
$message->fromAddress = $address;
$message->fromName = 'Someone';
$message->addLabel($label);
return $message;
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail\PostIngest;
use App\Domain\DTO\Mail\PostIngestResult;
use App\Domain\Enum\Mail\LabelRole;
use App\Entity\Label\Label;
use App\Entity\Mail\Message;
use App\Service\Mail\PostIngest\RecentMailPolicy;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* Spam and Trash are kept and not worked on.
*
* A Gmail import now takes in both, and so the pipeline that reads new mail
* for its meaning would have read them too: a model categorising spam, a fake
* delivery notice becoming a parcel card, a forged invitation put in the
* calendar. The rule lives on the message (Message::isSetAside()) and the
* steps ask it; these pin the answers they get.
*/
final class SetAsideMailTest extends TestCase
{
public function testSpamAndTrashAreSetAsideAndTheRestIsNot(): void
{
self::assertTrue($this->filedIn(LabelRole::Spam)->isSetAside());
self::assertTrue($this->filedIn(LabelRole::Trash)->isSetAside());
self::assertFalse($this->filedIn(LabelRole::Inbox)->isSetAside());
self::assertFalse($this->filedIn(LabelRole::Archive)->isSetAside());
self::assertFalse($this->filedIn(null)->isSetAside(), 'a user label is not a bin');
}
/** What insights, event extraction and event proposals are handed. */
public function testTheStepsThatReadMailForMeaningAreNotGivenSpamOrTrash(): void
{
$inbox = $this->filedIn(LabelRole::Inbox, id: 1);
$spam = $this->filedIn(LabelRole::Spam, id: 2);
$trash = $this->filedIn(LabelRole::Trash, id: 3);
$result = new PostIngestResult([$inbox, $spam, $trash], [], [], true);
$policy = new RecentMailPolicy(30);
self::assertSame([1], $policy->recentIds($result));
// Pairing a read receipt with the mail it is about reads nothing for
// meaning, and a receipt filtered into Spam is still that mail's.
self::assertSame([1, 2, 3], $policy->recentIds($result, includeSetAside: true));
}
private function filedIn(?LabelRole $role, ?int $id = null): Message
{
$label = new Label();
$label->role = $role;
$label->name = null === $role ? 'Receipts' : $role->value;
$message = new Message();
$message->receivedAt = new DateTimeImmutable('-1 day');
$message->addLabel($label);
if (null !== $id) {
new \ReflectionProperty(Message::class, 'id')->setValue($message, $id);
}
return $message;
}
}