mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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:
1 parent
cdc0ce2637
commit
fded408d31
9 files changed
+233
-3
No files matched your search
+9
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user