Hear mail read, unread, starred or unstarred in Outlook (#114)

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 12:33:17 +02:00
1 parent 31b4943a3f
commit 4738d514dd
8 files changed
+469 -23

No files matched your search

+15
View File
@@ -8,6 +8,21 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased ## Unreleased
**Mail read, unread, starred or unstarred in Outlook now reaches plMail.**
An Outlook account follows each folder with a change feed, and the feed was begun asking for a
message's id, folder and Message-ID only. Exchange reports a change in the properties a feed asks
for, so a message whose only change was being read, unread, flagged or unflagged was not reported
at all: mail handled in Outlook, on a phone or by a rule stayed as plMail had last seen it. (The
other direction, plMail to Outlook, was not affected.) The feed now asks for both. A feed already
running keeps what it was begun with, so after the upgrade every Outlook folder is listed once more
and followed from a new feed; the account shows as importing until that pass is done, which costs
its listing pages and fetches nothing it already has. Each message listed is also compared with the
server's read state and star, so the few that drifted while nothing was listening move to match the
server: a message plMail has as read that Outlook still has unread goes back to unread, and the
other way round, so a badge can go up as well as down. **Migration:** clears the stored change feeds
of Outlook accounts; nothing else changes in the schema.
[#114](https://github.com/karatektus/pl_mail/pull/114), by @Flouss.
**Saving an attachment to Paperless-ngx works.** The "Save to…" chooser listed your Paperless tags **Saving an attachment to Paperless-ngx works.** The "Save to…" chooser listed your Paperless tags
as folders to open, but a tag has nothing inside it, so clicking one only reloaded the same list and as folders to open, but a tag has nothing inside it, so clicking one only reloaded the same list and
the dialog seemed to do nothing. A tag is now the choice itself: click it and the attachment is filed the dialog seemed to do nothing. A tag is now the choice itself: click it and the attachment is filed
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Follow every Outlook folder again, with a delta that reports a read.
*
* A folder is followed by a delta link, and the link carries the $select the
* chain began with. Until now that left out isRead and flag, and Exchange
* does not send an entry for a message whose only change is one of those, so a
* message read or unread in Outlook, on a phone or by a rule was never heard
* of here. New chains now ask for both (GraphApiClient::DELTA_SELECT); the
* ones already stored cannot be widened, only replaced.
*
* Clearing the links is how: a folder without one is the import's, the next
* sync plans it, and the import lists the folder from the top with the new
* select and ends by storing a link that has it. What is already stored is
* recognised and not fetched again, so a mailbox that lost nothing costs its
* listing pages. Every listed message also has its read state and star
* compared with the server's, which settles the rows that drifted while
* nothing was listening.
*
* Until a folder's listing has finished the account shows as importing, which
* is what it is, and mail that arrives meanwhile is still picked up (see
* GraphApiSyncer::receivedSince()).
*/
final class Version20261014110000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Hand every Outlook folder back to the import once, so it is followed by a delta that reports read state';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
UPDATE account
SET graph_delta_links = '{}'
WHERE auth_type = 'oauth2' AND oauth_provider = 'microsoft'
SQL);
}
public function down(Schema $schema): void
{
// Nothing to put back: the old links were the ones that could not hear
// a read, and a listing that has run again is no less complete.
}
}
+9 -3
View File
@@ -45,6 +45,12 @@ parameters:
- tests - tests
- importmap.php - importmap.php
# Read for its classes, not analysed: migrations are generated and nobody
# edits them by hand, but a test that runs one (tests/Migrations) names its
# class, and without this that is a class PHPStan has never heard of.
scanDirectories:
- migrations
excludePaths: excludePaths:
analyseAndScan: analyseAndScan:
# Not PHP. # Not PHP.
@@ -70,9 +76,9 @@ parameters:
- playwright (?) - playwright (?)
- playwright-report (?) - playwright-report (?)
- test-results (?) - test-results (?)
# Generated: 54 migrations nobody edits by hand, and a ~1000-line # Generated: a ~1000-line dump of the framework config tree that
# dump of the framework config tree that exists to be read. # exists to be read. (migrations/ is not analysed either, by not
- migrations # being in `paths`; see scanDirectories.)
- config/reference.php (?) - config/reference.php (?)
# Twig extensions lifted from upstream rather than written here. # Twig extensions lifted from upstream rather than written here.
# NOTE: only Vendor/ — the rest of src/Twig is application code # NOTE: only Vendor/ — the rest of src/Twig is application code
+7 -3
View File
@@ -572,12 +572,16 @@ final class GraphApiSyncer
// Read state used to stop here. The comment above this block said // Read state used to stop here. The comment above this block said
// the only cheap thing that could have changed was location, and it // the only cheap thing that could have changed was location, and it
// was wrong: a Graph delta entry carries the whole message resource, // was wrong: a Graph delta entry carries the properties the chain
// isRead and flag included, so a message read in Outlook announced // was begun with, and with isRead and flag among them a message read
// itself in this very payload and plMail dropped it. Mail read on a // in Outlook announced itself in this very payload. Mail read on a
// phone stayed unread here, exactly as it did on IMAP, and for the // phone stayed unread here, exactly as it did on IMAP, and for the
// same reason — nothing ever re-read the state of a row it already // same reason — nothing ever re-read the state of a row it already
// had. // had.
//
// That holds only for a chain begun with them: GraphApiClient::
// DELTA_SELECT. One begun without is sent no entry at all for a
// message that was merely read, so this block cannot make up for it.
$state = $this->flagStateOf($account, $graphId, $item); $state = $this->flagStateOf($account, $graphId, $item);
if (null !== $state) { if (null !== $state) {
+14 -2
View File
@@ -114,8 +114,20 @@ final class GraphApiClient
*/ */
private array $inviteSelectUnsupported = []; private array $inviteSelectUnsupported = [];
/** Light projection used while planning — ids only, no bodies. */ /**
private const string DELTA_SELECT = 'id,internetMessageId,parentFolderId'; * Light projection a folder's delta is followed with — no bodies.
*
* isRead and flag are in it because a message delta reports a change only
* in the properties it was asked for. Without them Exchange sends no entry
* at all for a message that was merely read, unread, flagged or unflagged,
* so mail handled in Outlook or on a phone never reached plMail.
*
* The $select is sent once, on the call that begins the chain, and is the
* chain's from then on: the delta link carries it. Widening this therefore
* changes nothing for a link already stored — see the migration that
* hands every folder back to the import, and keep the two in step.
*/
private const string DELTA_SELECT = 'id,internetMessageId,parentFolderId,isRead,flag';
public function __construct( public function __construct(
// The scoped client from framework.yaml, not the default one: it // The scoped client from framework.yaml, not the default one: it
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Tests\Migrations;
use App\Domain\Enum\Account\AuthType;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\ORM\EntityManagerInterface;
use DoctrineMigrations\Version20261014110000;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* That every Outlook folder is handed back to the import once, so it is
* followed again by a delta that can report a read.
*
* The links on an account were made with a $select that leaves out isRead, and
* that select belongs to the link for good: changing what new chains ask for
* does nothing for the ones already running. Exchange sends them no entry for
* a message that was only read or unread, so the only way to hear about those
* is a chain begun again.
*
* Run against rows, not against the SQL text: the failure to guard against is
* a statement that is valid, applies, and touches the wrong accounts.
*/
final class GraphDeltaLinksResetMigrationTest extends KernelTestCase
{
private const array LINKS = [
'AAMkAD-inbox' => 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=old-inbox',
'AAMkAD-sent' => 'https://graph.microsoft.com/v1.0/me/mailFolders/y/messages/delta?$deltatoken=old-sent',
];
private EntityManagerInterface $em;
private Connection $connection;
private User $user;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testAMicrosoftAccountFollowsNoFolderAfterwards(): void
{
$outlook = $this->seedAccount('microsoft', self::LINKS);
self::assertSame(self::LINKS, $this->linksOf($outlook), 'the fixture starts out following both folders');
$this->migrate();
self::assertSame([], $this->linksOf($outlook), 'with no link a folder is the import\'s, and is listed again from the top');
}
/**
* The companion to the test above, and the half a careless statement
* breaks: `UPDATE account SET graph_delta_links = '{}'` with no WHERE is
* just as good at emptying the first account, and also empties these.
*/
public function testAccountsOfOtherProvidersAreLeftAlone(): void
{
$google = $this->seedAccount('google', self::LINKS);
$this->migrate();
self::assertSame(self::LINKS, $this->linksOf($google), 'a Gmail account does not use delta links, and the migration is not its business');
}
/**
* Idempotent in effect: a mailbox that is already being imported, or has no
* links to lose, comes out the same.
*/
public function testAnAccountWithNoLinksStaysWithNone(): void
{
$outlook = $this->seedAccount('microsoft', []);
$this->migrate();
self::assertSame([], $this->linksOf($outlook));
}
// ── helpers ───────────────────────────────────────────────────────────
/**
* Applies the migration's own statements, in this test's transaction, so the
* database it started with is the database it leaves.
*/
private function migrate(): void
{
// The migrations directory is read by Doctrine's own loader, not by
// Composer, so nothing has autoloaded the class by the time a test runs.
require_once dirname(__DIR__, 2) . '/migrations/Version20261014110000.php';
$migration = new Version20261014110000($this->connection, new NullLogger());
$migration->up(new Schema());
foreach ($migration->getSql() as $query) {
$this->connection->executeStatement($query->getStatement(), $query->getParameters(), $query->getTypes());
}
$this->em->clear();
}
/**
* @return array<string, string>
*/
private function linksOf(Account $account): array
{
$json = $this->connection->fetchOne('SELECT graph_delta_links FROM account WHERE id = :id', ['id' => $account->id]);
return json_decode((string) $json, true, 512, JSON_THROW_ON_ERROR);
}
/**
* @param array<string, string> $links
*/
private function seedAccount(string $provider, array $links): Account
{
$account = new Account();
$account->usr = $this->user;
$account->name = $provider . ' fixture';
$account->email = $provider . '-' . uniqid('', true) . '@example.test';
$account->username = uniqid($provider . '-', true);
$account->imapHost = 'imap.example.test';
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->authType = AuthType::OAuth2->value;
$account->oauthProvider = $provider;
$account->oauthAccessToken = 'test-access-token';
$account->oauthTokenExpiry = new \DateTimeImmutable('+1 day');
$account->isActive = true;
$account->graphDeltaLinks = $links;
$this->em->persist($account);
$this->em->flush();
return $account;
}
private function seedUser(): User
{
$user = new User();
$user->email = 'delta-reset-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Delta';
$user->nameLast = 'Reset';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Graph;
use App\Domain\Enum\Account\AuthType;
use App\Entity\Mail\Account;
use App\Service\Mail\GraphApiClient;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* What a folder's delta is asked to carry.
*
* A message delta reports a change only in the properties it was asked for,
* and the $select it begins with is the chain's for good. Asked for
* `id,internetMessageId,parentFolderId`, Exchange did not merely leave isRead
* out of the entries it sent: it sent no entry at all for a message whose only
* change was being read or unread. Mail read in Outlook or on a phone never
* reached plMail, and GraphInboundReadStateTest passed throughout, because it
* puts `isRead` into the delta it hands the syncer — a delta the real server
* could not have produced.
*
* So this pins the request, which is the one place the fixture cannot lie.
*/
final class GraphDeltaSelectTest extends KernelTestCase
{
private const string FOLDER = 'AAMkAD-inbox';
/** What the entries of a chain have to carry for a read or a star to be heard. */
private const array MUST_CARRY = ['isRead', 'flag'];
/** And what the syncer has always needed, so a rewrite of the list cannot drop it. */
private const array ALWAYS_CARRIED = ['id', 'internetMessageId', 'parentFolderId'];
public function testANewDeltaChainAsksForReadAndFlagState(): void
{
$select = $this->selectOf(fn (GraphApiClient $client, Account $account) => $client->deltaMessages($account, self::FOLDER, null));
foreach (self::MUST_CARRY as $property) {
self::assertContains($property, $select, sprintf('a delta that does not select %s is never told when it changes', $property));
}
foreach (self::ALWAYS_CARRIED as $property) {
self::assertContains($property, $select);
}
}
public function testAFoldersImportAsksForTheSameThing(): void
{
// The import's enumeration ends by handing the folder its delta link,
// so what it selects is what the folder is followed with afterwards.
$select = $this->selectOf(fn (GraphApiClient $client, Account $account) => $client->deltaMessagesPage($account, self::FOLDER, null, 50));
foreach (self::MUST_CARRY as $property) {
self::assertContains($property, $select, sprintf('a delta that does not select %s is never told when it changes', $property));
}
foreach (self::ALWAYS_CARRIED as $property) {
self::assertContains($property, $select);
}
}
// ── helpers ───────────────────────────────────────────────────────────
/**
* The properties named in the $select of the request the call makes.
*
* @param callable(GraphApiClient, Account): mixed $call
*
* @return list<string>
*/
private function selectOf(callable $call): array
{
$urls = [];
$http = new MockHttpClient(function (string $method, string $url) use (&$urls): MockResponse {
$urls[] = $url;
return new MockResponse(
json_encode(['value' => [], '@odata.deltaLink' => 'https://graph.microsoft.com/v1.0/delta?$deltatoken=t'], JSON_THROW_ON_ERROR),
['response_headers' => ['content-type' => 'application/json']],
);
});
$client = new GraphApiClient($http, self::getContainer()->get('App\Service\OAuth\OAuthTokenManager'));
$call($client, $this->account());
self::assertCount(1, $urls, 'one request, to begin the chain');
parse_str((string) parse_url($urls[0], PHP_URL_QUERY), $query);
self::assertArrayHasKey('$select', $query, 'the chain was begun without a $select, so it carries whatever Exchange defaults to');
return array_map('trim', explode(',', (string) $query['$select']));
}
/** Not stored: the token is current, so nothing asks the database. */
private function account(): Account
{
$account = new Account();
$account->name = 'Graph fixture';
$account->email = 'graph@example.test';
$account->username = 'graph-select';
$account->authType = AuthType::OAuth2->value;
$account->oauthProvider = 'microsoft';
$account->oauthAccessToken = 'test-access-token';
$account->oauthTokenExpiry = new DateTimeImmutable('+1 day');
$account->isActive = true;
return $account;
}
}
@@ -306,6 +306,57 @@ final class GraphInboundReadStateTest extends KernelTestCase
); );
} }
/**
* The half of a folder's second life that the migration relies on. Every
* folder's link is cleared once (Version20261014110000), so the folder is
* the import's and is listed from the top; the listing carries each
* message's isRead, and a message it already has is settled by it, not
* skipped as "known". Then the folder is followed again, by the link the
* listing ends with.
*
* Both reads are asserted — the message read here and unread there, and
* the other way round — because a listing that only ever agreed with one
* of them would pass either alone.
*/
public function testAFolderListedAgainSettlesTheReadStateOfWhatItAlreadyHas(): void
{
$readHere = $this->seedMessage(seen: true);
$unreadHere = $this->seedMessage(seen: false, graphId: 'AAkALgAAother');
$page = new MockResponse(json_encode([
'value' => [
['id' => self::GRAPH_ID, 'parentFolderId' => self::INBOX_FOLDER, 'isRead' => false],
['id' => 'AAkALgAAother', 'parentFolderId' => self::INBOX_FOLDER, 'isRead' => true],
],
'@odata.deltaLink' => 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=renewed',
], JSON_THROW_ON_ERROR), ['response_headers' => ['content-type' => 'application/json']]);
// What the migration leaves: no link, and the folder planned for the import.
$this->account->graphDeltaLinks = [];
$this->account->graphImport = [['folder' => self::INBOX_FOLDER, 'since' => time(), 'next' => null]];
$this->em->flush();
$this->syncerAnswering($page)->importPage($this->account);
$this->em->clear();
$readHere = $this->em->find(Message::class, $readHere->id);
$unreadHere = $this->em->find(Message::class, $unreadHere->id);
$account = $this->em->find(Account::class, $this->account->id);
self::assertNull($readHere->seenAt, 'read here, unread on the server: the server\'s word is taken');
self::assertNotNull($unreadHere->seenAt, 'unread here, read on the server: the server\'s word is taken');
self::assertSame(
'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=renewed',
$account->graphDeltaLinks[self::INBOX_FOLDER] ?? null,
'the listing ends by handing the folder the link it is followed with from now on',
);
// The re-listing changed rows the open list is showing, and it runs
// through importPage(), not sync(): that path has to tell the list too.
$this->assertListToldToRefresh();
}
// ── helpers ─────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────
/** /**
@@ -316,6 +367,25 @@ final class GraphInboundReadStateTest extends KernelTestCase
* @param array<string,mixed> $extra * @param array<string,mixed> $extra
*/ */
private function syncDelta(array $extra): void private function syncDelta(array $extra): void
{
$syncer = $this->syncerAnswering($this->deltaPage($extra));
// Folders that are already being followed. One without a delta link is
// the import's and would not be asked for changes at all — see
// GraphImportTest for that half.
$this->account->graphDeltaLinks = array_fill_keys([self::INBOX_FOLDER], 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=old');
$syncer->sync($this->account, [self::INBOX_FOLDER]);
$this->em->flush();
}
/**
* What a folder's delta, or its listing, says about the message.
*
* @param array<string,mixed> $extra
*/
private function deltaPage(array $extra): MockResponse
{ {
$delta = [ $delta = [
'value' => [array_merge([ 'value' => [array_merge([
@@ -325,13 +395,18 @@ final class GraphInboundReadStateTest extends KernelTestCase
'@odata.deltaLink' => 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=new', '@odata.deltaLink' => 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=new',
]; ];
return new MockResponse(json_encode($delta, JSON_THROW_ON_ERROR), [
'response_headers' => ['content-type' => 'application/json'],
]);
}
private function syncerAnswering(MockResponse $response): GraphApiSyncer
{
$container = self::getContainer(); $container = self::getContainer();
$syncer = new GraphApiSyncer( return new GraphApiSyncer(
new GraphApiClient( new GraphApiClient(
new MockHttpClient([new MockResponse(json_encode($delta, JSON_THROW_ON_ERROR), [ new MockHttpClient([$response]),
'response_headers' => ['content-type' => 'application/json'],
])]),
$container->get('App\Service\OAuth\OAuthTokenManager'), $container->get('App\Service\OAuth\OAuthTokenManager'),
), ),
$container->get('App\Service\Graph\GraphFolderResolver'), $container->get('App\Service\Graph\GraphFolderResolver'),
@@ -347,18 +422,9 @@ final class GraphInboundReadStateTest extends KernelTestCase
$container->get('App\Service\Label\ThreadLabelSynchronizer'), $container->get('App\Service\Label\ThreadLabelSynchronizer'),
new \App\Service\Mail\SyncNotifier($this->hub, new \Psr\Log\NullLogger()), new \App\Service\Mail\SyncNotifier($this->hub, new \Psr\Log\NullLogger()),
); );
// Folders that are already being followed. One without a delta link is
// the import's and would not be asked for changes at all — see
// GraphImportTest for that half.
$this->account->graphDeltaLinks = array_fill_keys([self::INBOX_FOLDER], 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=old');
$syncer->sync($this->account, [self::INBOX_FOLDER]);
$this->em->flush();
} }
private function seedMessage(bool $seen, bool $flagged = false): Message private function seedMessage(bool $seen, bool $flagged = false, string $graphId = self::GRAPH_ID): Message
{ {
$thread = new MessageThread(); $thread = new MessageThread();
$thread->account = $this->account; $thread->account = $this->account;
@@ -373,7 +439,7 @@ final class GraphInboundReadStateTest extends KernelTestCase
$message->thread = $thread; $message->thread = $thread;
$message->subject = 'Read elsewhere'; $message->subject = 'Read elsewhere';
$message->fromAddress = 'sender@example.test'; $message->fromAddress = 'sender@example.test';
$message->graphId = self::GRAPH_ID; $message->graphId = $graphId;
$message->receivedAt = new DateTimeImmutable('-1 hour'); $message->receivedAt = new DateTimeImmutable('-1 hour');
$message->sentAt = $message->receivedAt; $message->sentAt = $message->receivedAt;
$message->hasAttachments = false; $message->hasAttachments = false;