A draft synced from Outlook or Gmail opens in the editor, and one sent elsewhere stops being a draft (#164)

Synced drafts no longer get a sentAt, so the editor opens them. A migration clears it on rows the provider still files as drafts, and a draft that turns up in Sent is settled as sent.

Takes over #123.

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 20:42:20 +02:00
1 parent 550dd9cf89
commit ebd2570a59
22 files changed
+1958 -14

No files matched your search

+14 -2
View File
@@ -8,6 +8,17 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
**A draft you started in another mail app opens in plMail.**
A draft that arrived by sync (from Outlook or Gmail) was listed in its
conversation, and clicking it gave a "Not found" page: it had been marked as already sent, which the
editor refuses to open. It is now kept as the unsent draft it is, like one written in plMail. The
visible side effect is that a conversation holding such a draft is no longer moved up the list by the
draft's date, the same as for a draft written here. A draft you then send from Outlook or Gmail is
recognised as sent, and no longer offered for editing. **Migration:** drafts already stored that
the provider still files as drafts are made openable too; one that was sent elsewhere before the
upgrade is left as it was.
[#164](https://github.com/karatektus/pl_mail/pull/164), by @Flouss.
**Pull the mail list down to sync it, on a phone.** Reaching for the button at the top of the screen
is no longer the only way: pulling the mail list down from its top now syncs it, through the same
sync as that button. Drag past the *Release to sync* indicator and let go, and the sync icon spins
@@ -23,8 +34,9 @@ starring, archiving, a spam move, a copy of something you sent — and again a f
Outlook reported your own reading back, and it always read "New mail". A device switched on under
Settings → Notifications is now told when mail arrives in the Inbox of a mailbox that has finished
importing, and about nothing else. Spam, drafts, sent copies and an import in progress no longer
notify, and neither does a message whose arrival time plMail cannot work out from its headers. Reminders from Calendar alerts and the update notice for administrators
are separate pushes and unchanged. Apps that register through JMAP, and an Android phone through
notify, and neither does a message whose arrival time plMail cannot work out from its headers.
Reminders from Calendar alerts and the update notice for administrators are separate pushes and
unchanged. Apps that register through JMAP, and an Android phone through
Firebase, are still told about every change: they ask what moved. **Migration:** one boolean column
on `jmap_push_subscription`, false by default; devices registered through the in-app toggle (an id
starting `web-`) are switched to the new behaviour once, so nobody has to turn notifications off and
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Drafts synced from Outlook or Gmail before the builders stopped giving them a
* sentAt: give them back the null sentAt an unsent draft has, so the editor
* opens them (see Message::$sentAt and ComposeController::assertEditableDraft()).
*
* NARROWER THAN "EVERY ROW FLAGGED \Draft", on purpose. Before this change a
* draft sent from another client stayed `\Draft` here, because sync never
* rebuilds a stored row and nothing settled it. Clearing such a row's sentAt
* would make mail that has already gone out editable and sendable a second time,
* which is the danger Message::settleAsSent() exists to prevent. So only a row
* the provider still files as a draft, as of its last sync, is changed:
*
* - Gmail: its label ids hold DRAFT and not SENT, the test
* SyncGmailMessageBatchHandler applies;
* - Graph: it wears the Drafts label and not the Sent one, Graph's folders as
* GraphApiSyncer::attachFolderLabel() files them.
*
* A stale row of the other kind keeps its sentAt and stays refused by the
* editor, exactly as before, until the provider reports it and the sync settles
* it. IMAP rows are left alone: their stored flag is `Draft` without the
* backslash, which isDraft() does not read as a draft, so the builder change
* does not reach them either.
*
* Containment (@>) rather than jsonb's `?` operator: Doctrine prepares the
* statement, and a `?` is a parameter placeholder to it, so `?` would reach
* Postgres as `$1` and stop every upgrade at this migration.
*
* The thread's lastMessageAt is not touched. It was raised by the draft's date
* when the draft was stored; lowering it now would move conversations down the
* list on upgrade, for no one's benefit.
*/
final class Version20261015130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Clear sent_at on Gmail and Outlook drafts still filed as drafts, so the editor opens them';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
UPDATE message m
SET sent_at = NULL
WHERE m.sent_at IS NOT NULL
AND m.flags::jsonb @> '["\\Draft"]'
AND (
(
m.gmail_id IS NOT NULL
AND m.gmail_label_ids::jsonb @> '["DRAFT"]'
AND NOT m.gmail_label_ids::jsonb @> '["SENT"]'
)
OR (
m.graph_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM message_label ml JOIN label l ON l.id = ml.label_id
WHERE ml.message_id = m.id AND l.role = 'drafts'
)
AND NOT EXISTS (
SELECT 1 FROM message_label ml JOIN label l ON l.id = ml.label_id
WHERE ml.message_id = m.id AND l.role = 'sent'
)
)
)
SQL);
}
public function down(Schema $schema): void
{
// Nothing to put back: the old sentAt was the draft's receivedAt, which
// is still on the row, and putting it back would only make the drafts
// unopenable again.
}
}
+9
View File
@@ -47,6 +47,11 @@ final readonly class RemoteFlagState
* GraphMessageBuilder both synthesise one at ingest — so a refresh that
* left it alone would leave the mirror saying what was true months ago.
*
* A draft stays a draft through that rebuild. Those providers do not say
* whether a message is one in a refresh, and \Draft is not read or starred,
* so rebuilding from those two alone made the first re-read of a stored
* draft end it. When one is sent, Message::settleAsSent() says so.
*
* @return list<string>
*/
public function storedFlags(): array
@@ -57,6 +62,10 @@ final readonly class RemoteFlagState
$flags = [];
if (true === $this->message->isDraft()) {
$flags[] = MessageFlag::DRAFT->value;
}
if (true === $this->seen) {
$flags[] = MessageFlag::SEEN->value;
}
+45
View File
@@ -159,6 +159,14 @@ class Message extends MessageModel
/**
* When the message went out (for received mail, when it arrived).
*
* An unsent draft has none, however it came to be here: the editor, the
* send queue and the thread all read "has a sentAt" as "has gone out". So
* a sync builder must leave it null for a draft, or a draft written in
* another client is listed and then refused (404) by the editor.
*/
#[ORM\Column(nullable: true)]
public ?DateTimeImmutable $sentAt = null;
@@ -735,6 +743,43 @@ class Message extends MessageModel
return in_array($flag->value, $this->flags, true);
}
/**
* A stored draft that the provider says has been sent.
*
* Sync stores a draft once, as an unsent draft, and never rebuilds a stored
* row, so sending it from Outlook or Gmail left it flagged `\Draft` with no
* sentAt: the editor would open it, the send queue would send it again, and
* discarding it would delete the sent copy at the provider. This is the one
* place that says what "was sent" means for the row, for every provider.
*
* The thread is ranked by $at, as the local send does (ThreadOrderingOnSendTest),
* but never moved backwards. Its category is not re-adopted: that is the
* threader's, and a draft's reply context was settled when it was written.
*
* Anything that is not an unsent draft is left exactly as it is, since the
* sync asks about every stored message the provider says is not a draft.
*/
public function settleAsSent(DateTimeImmutable $at): void
{
if (false === $this->isDraft()) {
return;
}
$this->removeFlag(MessageFlag::DRAFT);
// A draft stored before synced drafts were kept unsent already has a
// sentAt (its receivedAt). That is the time it keeps, and the one the
// thread is ranked by: $at is only when this was noticed, and a mass
// re-read of old drafts must not float every conversation to the top.
$this->sentAt ??= $at;
$thread = $this->thread;
if (null !== $thread && (null === $thread->lastMessageAt || $this->sentAt > $thread->lastMessageAt)) {
$thread->lastMessageAt = $this->sentAt;
}
}
/**
* Whether a read receipt was asked for on this message.
*
@@ -10,6 +10,7 @@ use App\Domain\Enum\Mail\GmailSkipReason;
use App\Domain\DTO\Mail\IngestedMessage;
use App\Domain\DTO\Mail\RemoteFlagState;
use App\Domain\Helper\MessageIdHelper;
use DateTimeImmutable;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Jmap\State\JmapObjectType;
@@ -513,6 +514,15 @@ final readonly class SyncGmailMessageBatchHandler
);
if (true === $ownsFlags) {
// A stored draft whose message now carries SENT and no longer
// DRAFT was sent (Gmail swaps one label for the other on the same
// message). Only SENT says so, as only Sent Items does on Graph:
// a draft with neither label is not known to be sent. Ahead of the
// refresh below, so that it sees the row as it now is.
if (true === in_array('SENT', $labelIds, true) && false === in_array('DRAFT', $labelIds, true)) {
$existing->settleAsSent(new DateTimeImmutable());
}
// One row at a time because enrichment is one row at a time, and
// this is the call that recounts the thread, updates the unread
// badge and records the JMAP change — as well as declining to
@@ -10,6 +10,7 @@ use App\Entity\Mail\Account;
use App\Infrastructure\Messaging\Message\SyncGraphMessageBatchMessage;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MessageRepository;
use DateTimeImmutable;
use App\Service\Graph\GraphMessageBuilder;
use App\Service\HarvestContactsService;
use App\Service\Mail\GraphApiClient;
@@ -130,6 +131,27 @@ final readonly class SyncGraphMessageBatchHandler
// push here would wake every client for nothing. Label and
// flag changes arrive via GraphApiSyncer, which does record.
$existing->graphId = $graphId;
// And the full resource just fetched says whether this is
// still a draft. Filing a draft in Sent Items changes its
// id on a mailbox without immutable ids, so this is where
// that send is heard; the stored row would otherwise stay
// an unsent draft for good. Like the locator above it is
// not announced to JMAP clients: this handler has no
// change log. Only an explicit false counts: a payload that
// does not say is not an answer.
if (false === ($payload['isDraft'] ?? null)) {
$sentAt = $payload['sentDateTime'] ?? null;
try {
$at = is_string($sentAt) && '' !== $sentAt ? new DateTimeImmutable($sentAt) : new DateTimeImmutable();
} catch (\Exception) {
$at = new DateTimeImmutable();
}
$existing->settleAsSent($at);
}
continue;
}
}
+5 -2
View File
@@ -115,8 +115,11 @@ final class GmailMessageBuilder
? new DateTimeImmutable()->setTimestamp((int)($internalDateMs / 1000))
: new DateTimeImmutable();
$isDraft = true === in_array('DRAFT', $labelIds, true);
$message->receivedAt = $receivedAt;
$message->sentAt = $receivedAt;
// A draft has not been sent, whatever internalDate says (see Message::$sentAt).
$message->sentAt = true === $isDraft ? null : $receivedAt;
// ── Flags (derived from label IDs) ────────────────────────────────────
$flags = [];
@@ -131,7 +134,7 @@ final class GmailMessageBuilder
$message->starredAt = new DateTimeImmutable();
}
if (true === in_array('DRAFT', $labelIds, true)) {
if (true === $isDraft) {
$flags[] = '\\Draft';
}
+20 -2
View File
@@ -638,8 +638,10 @@ final class GraphApiSyncer
* Graph has no flag list, so the two booleans it does answer are turned
* into one: RemoteFlagState::storedFlags() synthesises the mirror. \Draft
* is deliberately not carried here — isDraft is a property of the message
* that GraphMessageBuilder sets at ingest and that a delta on a stored
* message does not change without the message being rewritten anyway.
* that GraphMessageBuilder sets at ingest, and a delta does not say when it
* changes. A draft being sent is heard elsewhere instead: attachFolderLabel()
* for a move into Sent Items, SyncGraphMessageBatchHandler for a new id
* (both Message::settleAsSent()).
*
* @param array<string,mixed> $item
*/
@@ -717,6 +719,22 @@ final class GraphApiSyncer
$message->addLabel($label);
// Filed in Sent Items, so it was sent. A delta says nothing about
// isDraft and a stored message is never rebuilt, so this is the only
// place a draft sent from Outlook (same id under immutable ids) is
// heard. Trash and Drafts are not it: a draft thrown away or put back
// is still unsent.
//
// A settle is a change of its own, even with no move: a draft filed in
// Sent Items before settling existed still wears \Draft beside its
// Sent label, and the next re-report is the first chance to end that.
// It changes the Email's keywords and the list's draft marker, so it
// is recorded and announced like a move.
if (LabelRole::Sent === $label->role && true === $message->isDraft()) {
$message->settleAsSent(new DateTimeImmutable());
$moved = true;
}
// A folder move on the Microsoft side changes Email.mailboxIds, which
// is the single most visible thing a JMAP client watches for. Without
// this the message silently moves in Outlook and never in ltt.rs.
+4 -2
View File
@@ -142,14 +142,16 @@ final class GraphMessageBuilder
// ── Dates ─────────────────────────────────────────────────────────────
$receivedAt = $this->parseDate($payload['receivedDateTime'] ?? null) ?? new DateTimeImmutable();
$sentAt = $this->parseDate($payload['sentDateTime'] ?? null);
$isDraft = true === ($payload['isDraft'] ?? false);
$message->receivedAt = $receivedAt;
$message->sentAt = $sentAt ?? $receivedAt;
// Outlook stamps a draft with a receivedDateTime and no sentDateTime;
// falling back to the first would make an unsent draft look sent.
$message->sentAt = true === $isDraft ? null : ($sentAt ?? $receivedAt);
// ── Flags ─────────────────────────────────────────────────────────────
$flags = [];
$isRead = true === ($payload['isRead'] ?? false);
$isDraft = true === ($payload['isDraft'] ?? false);
$flagged = 'notFlagged' !== (string) ($payload['flag']['flagStatus'] ?? 'notFlagged');
if (true === $isRead) {
+10 -5
View File
@@ -933,15 +933,20 @@ class MessageSyncer
$message->ccAddresses = self::addressesOf($imapMessage->getCc());
$message->bccAddresses = self::addressesOf($imapMessage->getBcc());
// Dates
$receivedAt = $this->receivedAtOf($imapMessage);
$message->sentAt = $receivedAt;
$message->receivedAt = $receivedAt;
// Flags
$flagNames = array_values($imapMessage->getFlags()->toArray());
$message->flags = $flagNames;
// Dates. A draft has not been sent, whatever its Date header says (see
// Message::$sentAt). Decided by the same test the rest of the app uses
// on the stored flags, so a row is never "not a draft, never sent":
// webklex strips the backslash, so a server's `\Draft` is stored as
// `Draft` here and is not a draft to the app yet.
$receivedAt = $this->receivedAtOf($imapMessage);
$message->sentAt = true === $message->isDraft() ? null : $receivedAt;
$message->receivedAt = $receivedAt;
if (
true === in_array('Seen', $flagNames, true)
|| true === in_array('\\Seen', $flagNames, true)
+3 -1
View File
@@ -68,7 +68,9 @@ final readonly class ReadReceiptPolicy
// Our own outgoing mail carries the flag too — it is one column for
// both directions — and a sent message is not something to answer.
if (null !== $message->sentAt) {
// Nor is a draft, which has no sentAt (see Message::$sentAt) but is
// just as much our own.
if (null !== $message->sentAt || true === $message->isDraft()) {
return ReadReceiptDecision::silent();
}
@@ -10,6 +10,7 @@ use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\User\User;
use App\Repository\User\UserRepository;
use App\Service\Graph\GraphMessageBuilder;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -208,6 +209,33 @@ final class ComposeSafetyTest extends WebTestCase
self::assertResponseStatusCodeSame(404);
}
/**
* A draft that arrived by sync opens like one written here.
*
* Outlook gives a draft a receivedDateTime and no sentDateTime, and the
* builder used to turn the first into sentAt, which the editor reads as
* "already sent": the thread offered the draft and the link answered 404.
* The same builder on the same payload minus the draft flag is the
* companion — a received mail must still be refused, or the guard above has
* been loosened rather than the draft fixed.
*/
public function testADraftSyncedFromTheProviderOpensInTheEditor(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'outlook@joder.dev', sortOrder: 0, primary: true);
$builder = static::getContainer()->get(GraphMessageBuilder::class);
$draft = $this->synced($builder, $account, draft: true);
$received = $this->synced($builder, $account, draft: false);
$client->request('GET', '/compose/edit/' . $received->id, server: self::DOCK_FRAME);
self::assertResponseStatusCodeSame(404);
$client->request('GET', '/compose/edit/' . $draft->id, server: self::DOCK_FRAME);
self::assertResponseIsSuccessful();
}
/** The stored body is not trusted on the way into the editor either. */
public function testScriptInADraftBodyIsNotRendered(): void
{
@@ -245,6 +273,26 @@ final class ComposeSafetyTest extends WebTestCase
return $message;
}
/** A message as the Graph sync stores it, draft or not. */
private function synced(GraphMessageBuilder $builder, Account $account, bool $draft): Message
{
$message = $builder->build([
'id' => 'graph-' . uniqid('', true),
'internetMessageId' => '<' . uniqid('', true) . '@example.test>',
'subject' => 'Synced',
'isDraft' => $draft,
'from' => ['emailAddress' => ['name' => 'Me', 'address' => $account->email]],
'toRecipients' => [['emailAddress' => ['address' => 'you@example.test']]],
'receivedDateTime' => '2026-10-14T14:15:51Z',
'body' => ['contentType' => 'html', 'content' => '<p>Hello</p>'],
], $account, []);
$this->em->persist($message);
$this->em->flush();
return $message;
}
private function boot(object $client): User
{
$container = static::getContainer();
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Tests\Domain\DTO\Mail;
use App\Domain\DTO\Mail\RemoteFlagState;
use App\Domain\Enum\Mail\MessageFlag;
use App\Entity\Mail\Message;
use PHPUnit\Framework\TestCase;
/**
* What a refresh writes back as the row's flags.
*
* Gmail and Graph never send a flag list, so the mirror is rebuilt from the two
* facts they do send (read, starred). That used to drop \Draft: the first time
* a stored draft was re-read it stopped being one, which the refresh's own
* comment says it must not do. A provider that does send its flags (IMAP) is
* still taken at its word, \Draft included or not.
*/
final class RemoteFlagStateTest extends TestCase
{
public function testARebuiltMirrorKeepsTheDraftFlag(): void
{
$draft = $this->message([MessageFlag::DRAFT->value, MessageFlag::SEEN->value]);
$state = new RemoteFlagState($draft, seen: true, flagged: false);
self::assertSame(
MessageFlag::canonicalList([MessageFlag::DRAFT->value, MessageFlag::SEEN->value]),
$state->storedFlags(),
);
}
/** Presence companion: the flag is carried, not invented. */
public function testARebuiltMirrorDoesNotMakeADraftOfAnythingElse(): void
{
$message = $this->message([MessageFlag::SEEN->value]);
$state = new RemoteFlagState($message, seen: true, flagged: false);
self::assertSame([MessageFlag::SEEN->value], $state->storedFlags());
}
/** A provider that lists its flags is the authority on them. */
public function testAProvidersOwnFlagListIsTakenAtItsWord(): void
{
$draft = $this->message([MessageFlag::DRAFT->value]);
$state = new RemoteFlagState($draft, seen: true, flagged: false, flags: ['\\Seen']);
self::assertSame([MessageFlag::SEEN->value], $state->storedFlags());
}
/**
* @param list<string> $flags
*/
private function message(array $flags): Message
{
$message = new Message();
$message->flags = $flags;
return $message;
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity\Mail;
use App\Domain\Enum\Mail\MessageFlag;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* A draft that was sent somewhere else stops being one here.
*
* Sync stores a draft once, as an unsent draft, and a delta on a stored message
* does not rewrite its flags. So when the user sends it from Outlook or Gmail
* the row stayed `\Draft` with no sentAt, which the editor opens and the send
* queue will send again. This is the one place that says what the draft
* becoming sent means for the row.
*/
final class MessageSettleAsSentTest extends TestCase
{
public function testADraftBecomesASentMessage(): void
{
$draft = $this->message([MessageFlag::DRAFT->value, MessageFlag::SEEN->value]);
$at = new DateTimeImmutable('2026-10-14 15:00:00');
$draft->settleAsSent($at);
self::assertFalse($draft->isDraft());
self::assertSame($at, $draft->sentAt);
// The rest of the flags are none of its business.
self::assertSame([MessageFlag::SEEN->value], $draft->flags);
}
public function testTheThreadIsRankedByTheTimeItWasSent(): void
{
$draft = $this->message([MessageFlag::DRAFT->value]);
$thread = new MessageThread();
$thread->lastMessageAt = new DateTimeImmutable('2026-10-14 09:00:00');
$draft->thread = $thread;
$draft->settleAsSent(new DateTimeImmutable('2026-10-14 15:00:00'));
self::assertSame('2026-10-14 15:00:00', $thread->lastMessageAt->format('Y-m-d H:i:s'));
}
/**
* A draft stored before synced drafts were kept unsent has a sentAt
* already (its receivedAt). It keeps it, and ranks the thread by it, not by
* the moment the sync noticed: otherwise re-reading old drafts, which an
* upgrade does to every Gmail mailbox, floats each of their conversations
* to the top.
*/
public function testADraftThatAlreadyHasASentAtKeepsItAndRanksByIt(): void
{
$draft = $this->message([MessageFlag::DRAFT->value]);
$thread = new MessageThread();
$draft->sentAt = new DateTimeImmutable('2026-10-14 10:00:00');
$thread->lastMessageAt = new DateTimeImmutable('2026-10-14 09:00:00');
$draft->thread = $thread;
$draft->settleAsSent(new DateTimeImmutable('2026-10-14 15:00:00'));
self::assertFalse($draft->isDraft());
self::assertSame('2026-10-14 10:00:00', $draft->sentAt->format('Y-m-d H:i:s'));
self::assertSame('2026-10-14 10:00:00', $thread->lastMessageAt->format('Y-m-d H:i:s'));
}
/** Never backwards: a newer message in the thread keeps its place. */
public function testANewerMessageInTheThreadKeepsItsRank(): void
{
$draft = $this->message([MessageFlag::DRAFT->value]);
$thread = new MessageThread();
$thread->lastMessageAt = new DateTimeImmutable('2026-10-14 16:00:00');
$draft->thread = $thread;
$draft->settleAsSent(new DateTimeImmutable('2026-10-14 15:00:00'));
self::assertSame('2026-10-14 16:00:00', $thread->lastMessageAt->format('Y-m-d H:i:s'));
}
/**
* Anything that is not an unsent draft is left exactly as it is: the sync
* calls this for every stored message whose provider says it is not a
* draft, which is nearly all of them.
*/
public function testAMessageThatIsNotADraftIsUntouched(): void
{
$sent = $this->message([MessageFlag::SEEN->value]);
$before = new DateTimeImmutable('2026-10-14 10:00:00');
$thread = new MessageThread();
$sent->sentAt = $before;
$thread->lastMessageAt = new DateTimeImmutable('2026-10-14 09:00:00');
$sent->thread = $thread;
$sent->settleAsSent(new DateTimeImmutable('2026-10-14 15:00:00'));
self::assertSame($before, $sent->sentAt);
self::assertSame([MessageFlag::SEEN->value], $sent->flags);
self::assertSame('2026-10-14 09:00:00', $thread->lastMessageAt->format('Y-m-d H:i:s'));
}
/**
* @param list<string> $flags
*/
private function message(array $flags): Message
{
$message = new Message();
$message->flags = $flags;
return $message;
}
}
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Tests\Infrastructure\Messaging\Handler;
use App\Domain\Enum\Account\AuthType;
use App\Domain\Enum\Account\MailProvider;
use App\Domain\Enum\Mail\MessageFlag;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Infrastructure\Messaging\Handler\SyncGraphMessageBatchHandler;
use App\Infrastructure\Messaging\Message\SyncGraphMessageBatchMessage;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MessageRepository;
use App\Service\Graph\GraphMessageBuilder;
use App\Service\HarvestContactsService;
use App\Service\Mail\GraphApiClient;
use App\Service\Mail\PostIngestPipeline;
use App\Service\Mail\SyncNotifier;
use App\Service\Mail\SyncOrigin;
use App\Service\OAuth\OAuthTokenManager;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* A draft sent from Outlook, when Outlook gives the sent copy a new id.
*
* Without immutable ids (personal outlook.com) filing a draft in Sent Items
* changes its id. The handler recognises the message by its RFC Message-ID,
* keeps the stored row and only re-points the locator, so the row stayed an
* unsent draft: openable in the editor, and sendable a second time. The full
* resource it has just fetched says isDraft, and that is the answer.
*
* The same message fetched again and still a draft is the companion: the
* handler must not settle everything it re-points.
*/
final class SyncGraphDraftSentElsewhereTest extends KernelTestCase
{
private const string OLD_ID = 'AAkALgAAdraft-old';
private const string NEW_ID = 'AAkALgAAdraft-new';
private const string RFC_ID = '<draft-elsewhere@example.test>';
private EntityManagerInterface $em;
private Connection $connection;
private Account $account;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->connection = self::getContainer()->get(Connection::class);
$this->connection->beginTransaction();
$this->seed();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testTheSentCopyOfAStoredDraftSettlesTheDraft(): void
{
$draft = $this->seedDraft();
$this->sync(isDraft: false);
$this->em->refresh($draft);
// Presence: it is the same row, re-pointed, not a second message.
self::assertSame(self::NEW_ID, $draft->graphId);
self::assertFalse($draft->isDraft());
// sentDateTime from the fetched resource: 2026-10-14T14:20:00Z.
self::assertSame(gmmktime(14, 20, 0, 10, 14, 2026), $draft->sentAt?->getTimestamp());
}
public function testTheSameMessageStillADraftStaysADraft(): void
{
$draft = $this->seedDraft();
$this->sync(isDraft: true);
$this->em->refresh($draft);
self::assertSame(self::NEW_ID, $draft->graphId);
self::assertTrue($draft->isDraft());
self::assertNull($draft->sentAt);
}
/** Absence is not an answer: a payload that omits isDraft settles nothing. */
public function testAPayloadThatDoesNotSayIfItIsADraftSettlesNothing(): void
{
$draft = $this->seedDraft();
$this->sync(isDraft: null);
$this->em->refresh($draft);
// Presence: the handler did meet it and re-point it.
self::assertSame(self::NEW_ID, $draft->graphId);
self::assertTrue($draft->isDraft());
self::assertNull($draft->sentAt);
}
// ── fixture ───────────────────────────────────────────────────────────
private function sync(?bool $isDraft): void
{
$container = self::getContainer();
$client = new MockHttpClient(function (string $method, string $url, array $options) use ($isDraft): MockResponse {
$body = json_decode((string) ($options['body'] ?? '{}'), true);
$subs = [];
foreach ($body['requests'] ?? [] as $request) {
$subs[] = ['id' => (string) $request['id'], 'status' => 200, 'body' => $this->payload($isDraft)];
}
return new MockResponse(
json_encode(['responses' => $subs], JSON_THROW_ON_ERROR),
['response_headers' => ['content-type' => 'application/json']],
);
});
$handler = new SyncGraphMessageBatchHandler(
$container->get(MessageRepository::class),
$container->get(AccountRepository::class),
new GraphApiClient($client, $container->get(OAuthTokenManager::class)),
$container->get(GraphMessageBuilder::class),
$container->get(HarvestContactsService::class),
$container->get(SyncNotifier::class),
$container->get('messenger.default_bus'),
$container->get(PostIngestPipeline::class),
$this->em,
new NullLogger(),
$container->get(SyncOrigin::class),
);
$handler(new SyncGraphMessageBatchMessage((int) $this->account->id, [self::NEW_ID]));
$this->em->flush();
}
/** @return array<string,mixed> */
private function payload(?bool $isDraft): array
{
$payload = [
'id' => self::NEW_ID,
'internetMessageId' => self::RFC_ID,
'conversationId' => 'conv-draft',
'subject' => 'Sent from Outlook',
'from' => ['emailAddress' => ['name' => 'Me', 'address' => 'graph-draft-fixture@example.test']],
'toRecipients' => [['emailAddress' => ['address' => 'you@example.test']]],
'receivedDateTime' => '2026-10-14T14:15:51Z',
'sentDateTime' => false === $isDraft ? '2026-10-14T14:20:00Z' : null,
'isRead' => true,
'body' => ['contentType' => 'text', 'content' => 'Hello'],
'bodyPreview' => 'Hello',
'hasAttachments' => false,
'parentFolderId' => false === $isDraft ? 'sent-folder' : 'drafts-folder',
];
if (null !== $isDraft) {
$payload['isDraft'] = $isDraft;
}
return $payload;
}
/** A draft as the sync stored it before it was sent: \Draft, no sentAt. */
private function seedDraft(): Message
{
// The handler finds the stored message through its thread or mailbox,
// and a synced draft always has a thread.
$thread = new MessageThread();
$thread->account = $this->account;
$thread->subject = 'Sent from Outlook';
$thread->normalizedSubject = 'sent from outlook';
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
$thread->lastMessageAt = new DateTimeImmutable('-1 day');
$draft = new Message();
$draft->account = $this->account;
$draft->thread = $thread;
$draft->subject = 'Sent from Outlook';
$draft->fromAddress = $this->account->email;
$draft->messageId = 'draft-elsewhere@example.test';
$draft->graphId = self::OLD_ID;
$draft->receivedAt = new DateTimeImmutable('2026-10-14 14:15:51');
$draft->hasAttachments = false;
$draft->flags = [MessageFlag::DRAFT->value];
$draft->syncedAt = new DateTimeImmutable();
$thread->addMessage($draft);
$this->em->persist($thread);
$this->em->persist($draft);
$this->em->flush();
return $draft;
}
private function seed(): void
{
$user = new User();
$user->email = 'graph-draft-sent-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Graph';
$user->nameLast = 'DraftSent';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$this->account = new Account();
$this->account->usr = $user;
$this->account->email = 'graph-draft-fixture@example.test';
$this->account->username = 'graph-draft-fixture@example.test';
$this->account->imapHost = 'outlook.office365.com';
$this->account->imapPort = 993;
$this->account->imapEncryption = 'ssl';
$this->account->smtpHost = 'smtp.office365.com';
$this->account->smtpPort = 587;
$this->account->smtpEncryption = 'starttls';
$this->account->password = 'x';
$this->account->authType = AuthType::OAuth2->value;
$this->account->oauthProvider = MailProvider::Microsoft->value;
$this->account->isActive = true;
$this->account->oauthAccessToken = 'test-access-token';
$this->account->oauthTokenExpiry = new DateTimeImmutable('+1 day');
$this->em->persist($this->account);
$this->em->flush();
$user->addAccount($this->account);
$this->em->flush();
}
}
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Tests\Migrations;
use App\Domain\Enum\Account\AuthType;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageFlag;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Label\Label;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\ORM\EntityManagerInterface;
use DoctrineMigrations\Version20261015130000;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* That a draft synced before the upgrade opens in the editor afterwards, and
* that a draft already sent from another client does not become sendable.
*
* Every row is seeded the way the old builders stored it: flagged `\Draft`, with
* its receivedAt as its sentAt. What tells the two kinds apart is where the
* provider files the message, so each "cleared" case has a "kept" case beside it
* that differs only in that.
*
* Run against rows, not against the SQL text: the failure to guard against is a
* statement that is valid, applies, and touches the wrong drafts.
*/
final class SyncedDraftSentAtMigrationTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private User $user;
private Account $account;
private Label $drafts;
private Label $sent;
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();
$this->account = $this->seedAccount();
$this->drafts = $this->seedLabel('Drafts', LabelRole::Drafts);
$this->sent = $this->seedLabel('Sent', LabelRole::Sent);
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testAGmailDraftStillLabelledDraftLosesItsSentAt(): void
{
$draft = $this->seed(gmailLabels: ['DRAFT']);
$this->migrate();
self::assertNull($this->sentAtOf($draft), 'presence: still a draft at Gmail, so it is an unsent draft here');
}
public function testAGmailDraftThatGmailNowLabelsSentKeepsItsSentAt(): void
{
$sent = $this->seed(gmailLabels: ['SENT']);
$this->migrate();
self::assertNotNull($this->sentAtOf($sent), 'sent from Gmail: clearing it would make sent mail sendable again');
}
public function testAnOutlookDraftInDraftsLosesItsSentAt(): void
{
$draft = $this->seed(labels: [$this->drafts]);
$this->migrate();
self::assertNull($this->sentAtOf($draft), 'presence: still in Drafts at Outlook');
}
public function testAnOutlookDraftFiledInSentKeepsItsSentAt(): void
{
$sent = $this->seed(labels: [$this->sent]);
$this->migrate();
self::assertNotNull($this->sentAtOf($sent), 'filed in Sent Items: it went out');
}
/** The flag is the other half of the test: a message in Drafts that is not flagged a draft is not one. */
public function testAMessageInDraftsThatIsNotFlaggedADraftKeepsItsSentAt(): void
{
$message = $this->seed(labels: [$this->drafts], flags: []);
$this->migrate();
self::assertNotNull($this->sentAtOf($message));
}
/** IMAP rows are no business of this: their stored flag never reads as a draft. */
public function testARowFromNeitherProviderKeepsItsSentAt(): void
{
$message = $this->seed(labels: [$this->drafts], graph: false);
$this->migrate();
self::assertNotNull($this->sentAtOf($message));
}
// ── 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/Version20261015130000.php';
$migration = new Version20261015130000($this->connection, new NullLogger());
$migration->up(new Schema());
// Prepared, as Doctrine Migrations runs it, not executed raw: a raw
// statement lets a jsonb `?` through that a prepared one reads as a
// placeholder, and that difference once broke this migration on upgrade.
foreach ($migration->getSql() as $query) {
$this->connection->prepare($query->getStatement())->executeStatement();
}
$this->em->clear();
}
private function sentAtOf(Message $message): mixed
{
return $this->connection->fetchOne('SELECT sent_at FROM message WHERE id = :id', ['id' => $message->id]);
}
/**
* A draft as the old builders stored it: `\Draft`, and its receivedAt as its
* sentAt. Gmail's when $gmailLabels is given, Graph's otherwise, unless
* $graph is false, which leaves it with neither provider's id.
*
* @param list<string>|null $gmailLabels
* @param list<Label> $labels
* @param list<string> $flags
*/
private function seed(
?array $gmailLabels = null,
array $labels = [],
array $flags = [MessageFlag::DRAFT->value],
bool $graph = true,
): Message {
$at = new DateTimeImmutable('-1 hour');
$thread = new MessageThread();
$thread->account = $this->account;
$thread->subject = 'A draft';
$thread->normalizedSubject = 'a draft';
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
$thread->lastMessageAt = $at;
$message = new Message();
$message->account = $this->account;
$message->thread = $thread;
$message->subject = 'A draft';
$message->fromAddress = 'drafts@example.test';
$message->receivedAt = $at;
$message->sentAt = $at;
$message->hasAttachments = false;
$message->flags = $flags;
$message->syncedAt = new DateTimeImmutable();
if (null !== $gmailLabels) {
$message->gmailId = uniqid('gmail-', true);
$message->gmailLabelIds = $gmailLabels;
} elseif (true === $graph) {
$message->graphId = uniqid('graph-', true);
}
foreach ($labels as $label) {
$message->addLabel($label);
}
$thread->addMessage($message);
$this->em->persist($thread);
$this->em->persist($message);
$this->em->flush();
// The prePersist listeners do not touch sentAt, but the claim of this
// test rests on it being there, so it is checked rather than assumed.
self::assertNotNull($this->sentAtOf($message), 'the fixture starts out with a sentAt');
return $message;
}
private function seedLabel(string $name, LabelRole $role): Label
{
$label = new Label();
$label->usr = $this->user;
$label->name = $name;
$label->role = $role;
$label->isVisible = true;
$this->em->persist($label);
$this->em->flush();
return $label;
}
private function seedAccount(): Account
{
$account = new Account();
$account->usr = $this->user;
$account->name = 'Drafts fixture';
$account->email = 'drafts-' . uniqid('', true) . '@example.test';
$account->username = uniqid('drafts-', true);
$account->imapHost = 'imap.example.test';
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->authType = AuthType::OAuth2->value;
$account->oauthProvider = 'microsoft';
$account->oauthAccessToken = 'test-access-token';
$account->oauthTokenExpiry = new DateTimeImmutable('+1 day');
$account->isActive = true;
$this->em->persist($account);
$this->em->flush();
return $account;
}
private function seedUser(): User
{
$user = new User();
$user->email = 'draft-migration-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Draft';
$user->nameLast = 'Migration';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Gmail;
use App\Domain\Enum\Mail\MessageFlag;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Service\Gmail\GmailMessageBuilder;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* An unsent draft has no sentAt, whoever wrote it.
*
* Gmail stamps a draft with an internalDate like any other message, and the
* builder turned that into both receivedAt and sentAt. Everything that reads
* "sentAt is set" as "this has gone out" (the editor above all, which answers
* 404 for a message that is not an unsent draft) then refused a draft that
* arrived by sync. receivedAt stays: it is when the row appeared.
*/
final class GmailDraftSentAtTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private GmailMessageBuilder $builder;
private Account $account;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->builder = $container->get(GmailMessageBuilder::class);
$this->connection->beginTransaction();
$this->account = $this->seedAccount();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testADraftHasNoSentAtButKeepsItsReceivedAt(): void
{
$message = $this->builder->build($this->payload(['DRAFT']), $this->account);
// Presence companions: it IS a draft, and it still has its date.
self::assertTrue($message->hasFlag(MessageFlag::DRAFT));
self::assertSame(gmmktime(14, 15, 51, 10, 14, 2026), $message->receivedAt?->getTimestamp());
self::assertNull($message->sentAt);
}
public function testAnInboxMessageStillGetsItsDateAsSentAt(): void
{
$message = $this->builder->build($this->payload(['INBOX']), $this->account);
self::assertFalse($message->hasFlag(MessageFlag::DRAFT));
self::assertSame(gmmktime(14, 15, 51, 10, 14, 2026), $message->sentAt?->getTimestamp());
}
/**
* @param list<string> $labelIds
*
* @return array<string,mixed>
*/
private function payload(array $labelIds): array
{
return [
'id' => 'draft-' . uniqid('', true),
'threadId' => 'thread-draft',
'labelIds' => $labelIds,
'internalDate' => (string) (gmmktime(14, 15, 51, 10, 14, 2026) * 1000),
'payload' => [
'mimeType' => 'text/plain',
'headers' => [
['name' => 'Subject', 'value' => 'Draft or not'],
['name' => 'From', 'value' => 'me@example.test'],
['name' => 'Message-ID', 'value' => '<draft-' . uniqid('', true) . '@example.test>'],
],
'body' => ['data' => rtrim(strtr(base64_encode('Hi'), '+/', '-_'), '=')],
],
];
}
private function seedAccount(): Account
{
$user = new User();
$user->email = 'gmail-draft-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Gmail';
$user->nameLast = 'Draft';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$account = new Account();
$account->usr = $user;
$account->email = 'gmail-draft-' . uniqid('', true) . '@example.test';
$account->username = $account->email;
$account->imapHost = 'localhost';
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->smtpHost = 'localhost';
$account->smtpPort = 587;
$account->smtpEncryption = 'starttls';
$account->password = 'x';
$account->authType = 'oauth_google';
$account->isActive = true;
$this->em->persist($account);
$this->em->flush();
return $account;
}
}
@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Gmail;
use Psr\Log\NullLogger;
use App\Domain\Enum\Account\AuthType;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageFlag;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Label\Label;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Infrastructure\Messaging\Handler\SyncGmailMessageBatchHandler;
use App\Infrastructure\Messaging\Message\SyncGmailMessageBatchMessage;
use App\Service\Mail\GmailApiClient;
use App\Service\Mail\SyncNotifier;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Mercure\HubInterface;
/**
* A draft sent from Gmail stops being a draft here.
*
* Gmail keeps the message and swaps its labels: DRAFT comes off and SENT goes
* on. Enrichment re-reads exactly that label set, but only mirrored the labels
* and the read state, so the stored row stayed an unsent draft: the editor
* would open it and the send queue would send it again. The label set is
* authoritative, so a stored draft whose message no longer carries DRAFT is
* settled as sent; one that still carries it is left alone.
*/
final class GmailDraftSentElsewhereTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private User $user;
private Account $account;
private const string GMAIL_ID = 'gmail-message-1';
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();
$this->account = $this->seedAccount();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testADraftThatGmailNowLabelsSentIsNoLongerADraft(): void
{
$draft = $this->seedDraft();
$before = new DateTimeImmutable();
$this->handle(['SENT']);
$this->em->refresh($draft);
self::assertFalse($draft->isDraft());
self::assertNotNull($draft->sentAt);
self::assertGreaterThanOrEqual($before->getTimestamp(), $draft->sentAt->getTimestamp());
}
/** Neither label: not known to be sent, so not settled. */
public function testADraftThatGmailLabelsNeitherDraftNorSentStaysADraft(): void
{
$draft = $this->seedDraft();
$this->handle(['TRASH']);
$this->em->refresh($draft);
self::assertTrue($draft->isDraft());
self::assertNull($draft->sentAt);
}
public function testADraftThatGmailStillLabelsDraftStaysADraft(): void
{
$draft = $this->seedDraft();
$this->handle(['DRAFT']);
$this->em->refresh($draft);
self::assertTrue($draft->isDraft());
self::assertNull($draft->sentAt);
}
// ── helpers ───────────────────────────────────────────────────────────
/**
* Run the batch handler for our one message, with Gmail reporting these
* labelIds for it.
*
* GmailApiClient is final, so it is built for real against a
* MockHttpClient — the way GmailApiSyncerHistoryTest does it — and the
* response is a genuine multipart/mixed batch body, because that is what
* the client parses.
*
* @param list<string> $labelIds
*
* @return int how many HTTP requests the client made
*/
private function handle(array $labelIds, bool $onlyMissing = false): int
{
$requests = 0;
$payload = json_encode([
'id' => self::GMAIL_ID,
'threadId' => 'gmail-thread-1',
'labelIds' => $labelIds,
'payload' => [
'headers' => [
['name' => 'Message-ID', 'value' => '<gmail-1@example.test>'],
['name' => 'Subject', 'value' => 'Sent from Gmail'],
['name' => 'From', 'value' => 'sender@example.test'],
['name' => 'To', 'value' => 'gmail@example.test'],
],
],
], JSON_THROW_ON_ERROR);
$boundary = 'batch_plmail_test';
$body = "--{$boundary}\r\n"
. "Content-Type: application/http\r\n"
. "Content-ID: <response-{$this->contentId()}>\r\n"
. "\r\n"
. "HTTP/1.1 200 OK\r\n"
. "Content-Type: application/json\r\n"
. "\r\n"
. $payload . "\r\n"
. "--{$boundary}--";
$httpClient = new MockHttpClient(function () use ($body, $boundary, &$requests): MockResponse {
++$requests;
return new MockResponse($body, [
'response_headers' => ['content-type' => 'multipart/mixed; boundary="' . $boundary . '"'],
]);
});
$container = self::getContainer();
$handler = new SyncGmailMessageBatchHandler(
$container->get('App\Repository\Mail\MessageRepository'),
$container->get('App\Repository\Mail\AccountRepository'),
new GmailApiClient($httpClient, $container->get('App\Service\OAuth\OAuthTokenManager'), new \App\Service\Gmail\GmailQuotaPacer(new \Symfony\Component\Clock\MockClock())),
$container->get('App\Service\Gmail\GmailMessageBuilder'),
$container->get('App\Service\Gmail\GmailAddressFilter'),
$container->get('App\Service\HarvestContactsService'),
// The real notifier publishes to Mercure, which the test kernel has
// no signing key for. What it announces is not what is under test.
new SyncNotifier($this->createStub(HubInterface::class), new NullLogger()),
$container->get('messenger.default_bus'),
$container->get('App\Service\Mail\PostIngestPipeline'),
$this->em,
$container->get('App\Jmap\State\StateManager'),
new \Psr\Log\NullLogger(),
$container->get('App\Service\Label\ThreadLabelSynchronizer'),
$container->get('App\Service\Mail\ThreadStatusUpdater'),
$container->get('App\Service\Mail\MessageCategorizer'),
$container->get('App\Service\Imap\MessageThreader'),
new \App\Service\Mail\SyncOrigin(),
new \App\Service\Gmail\GmailQuotaPacer(new \Symfony\Component\Clock\MockClock()),
$container->get(\App\Repository\Mail\GmailSkippedMessageRepository::class),
);
$handler(new SyncGmailMessageBatchMessage((int) $this->account->id, [self::GMAIL_ID], null, $onlyMissing));
$this->em->flush();
return $requests;
}
private function contentId(): string
{
return self::GMAIL_ID;
}
/** A draft as the sync stored it: \Draft, no sentAt. */
private function seedDraft(): Message
{
$thread = new MessageThread();
$thread->account = $this->account;
$thread->subject = 'Sent from Gmail';
$thread->normalizedSubject = 'sent from gmail';
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
$thread->lastMessageAt = new DateTimeImmutable('-1 day');
$message = new Message();
$message->account = $this->account;
$message->thread = $thread;
$message->subject = 'Sent from Gmail';
$message->fromAddress = 'gmail@example.test';
$message->messageId = 'gmail-1@example.test';
$message->gmailId = self::GMAIL_ID;
$message->receivedAt = new DateTimeImmutable('-1 hour');
$message->hasAttachments = false;
$message->flags = [MessageFlag::DRAFT->value];
$message->syncedAt = new DateTimeImmutable();
$thread->addMessage($message);
$this->em->persist($thread);
$this->em->persist($message);
$this->em->flush();
return $message;
}
private function seedAccount(): Account
{
$account = new Account();
$account->usr = $this->user;
$account->name = 'Gmail fixture';
$account->email = 'gmail@example.test';
$account->username = uniqid('gmail-', true);
$account->imapHost = 'imap.gmail.com';
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->authType = AuthType::OAuth2->value;
$account->oauthProvider = 'google';
$account->oauthAccessToken = 'test-access-token';
$account->oauthTokenExpiry = new DateTimeImmutable('+1 day');
$account->isActive = true;
$this->em->persist($account);
$this->em->flush();
$this->user->addAccount($account);
$this->em->flush();
return $account;
}
private function seedUser(): User
{
$user = new User();
$user->email = 'gmail-draft-sent-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Gmail';
$user->nameLast = 'DraftSent';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Graph;
use App\Domain\Enum\Mail\MessageFlag;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Service\Graph\GraphMessageBuilder;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* An unsent draft has no sentAt, whoever wrote it.
*
* The editor, the send queue and the thread both read "sentAt is set" as "this
* has gone out". Outlook gives a draft a receivedDateTime (when it was created)
* and no sentDateTime, and the builder used to fall back from the second to the
* first, so a draft that arrived by sync looked sent and the editor answered
* 404 for it.
*
* receivedAt is untouched: it is when the row appeared, and a draft is still
* ordered and listed by it.
*/
final class GraphDraftSentAtTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private GraphMessageBuilder $builder;
private Account $account;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->builder = $container->get(GraphMessageBuilder::class);
$this->connection->beginTransaction();
$this->account = $this->seedAccount();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testADraftHasNoSentAtButKeepsItsReceivedAt(): void
{
$message = $this->builder->build($this->payload(draft: true), $this->account, []);
// Presence companions: it IS a draft, and it still has its date.
self::assertTrue($message->hasFlag(MessageFlag::DRAFT));
self::assertSame(gmmktime(14, 15, 51, 10, 14, 2026), $message->receivedAt?->getTimestamp());
self::assertNull($message->sentAt);
}
public function testAReceivedMessageStillGetsItsReceivedAtAsSentAt(): void
{
$message = $this->builder->build($this->payload(draft: false), $this->account, []);
self::assertFalse($message->hasFlag(MessageFlag::DRAFT));
self::assertSame(gmmktime(14, 15, 51, 10, 14, 2026), $message->sentAt?->getTimestamp());
}
public function testASentMessageKeepsItsOwnSentDateTime(): void
{
$payload = $this->payload(draft: false);
$payload['sentDateTime'] = '2026-10-14T14:10:00Z';
$message = $this->builder->build($payload, $this->account, []);
self::assertSame(gmmktime(14, 10, 0, 10, 14, 2026), $message->sentAt?->getTimestamp());
}
/**
* @return array<string,mixed>
*/
private function payload(bool $draft): array
{
return [
'id' => 'graph-' . uniqid('', true),
'internetMessageId' => '<' . uniqid('', true) . '@example.test>',
'subject' => 'Draft or not',
'isDraft' => $draft,
'from' => ['emailAddress' => ['name' => 'Me', 'address' => 'me@example.test']],
'toRecipients' => [['emailAddress' => ['address' => 'you@example.test']]],
'receivedDateTime' => '2026-10-14T14:15:51Z',
'body' => ['contentType' => 'html', 'content' => '<p>Hello</p>'],
];
}
private function seedAccount(): Account
{
$user = new User();
$user->email = 'graph-draft-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Graph';
$user->nameLast = 'Draft';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$account = new Account();
$account->usr = $user;
$account->email = 'Graph Draft Fixture';
$account->username = 'graph-draft-fixture@example.test';
$account->imapHost = 'localhost';
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->smtpHost = 'localhost';
$account->smtpPort = 587;
$account->smtpEncryption = 'starttls';
$account->password = 'x';
$account->authType = 'password';
$account->isActive = true;
$this->em->persist($account);
$this->em->flush();
return $account;
}
}
@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Graph;
use App\Domain\Enum\Account\AuthType;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageFlag;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Label\Label;
use App\Entity\Label\LabelBinding;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Service\Graph\GraphApiSyncer;
use App\Service\Mail\GraphApiClient;
use App\Tests\Support\Mercure\RecordingHub;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* A draft sent from Outlook stops being a draft here.
*
* With immutable ids the draft keeps its id when Outlook files it in Sent
* Items, so what plMail hears is an ordinary delta entry for a message it
* already holds, now in another folder. The delta says nothing about isDraft
* and a stored message is never rebuilt, so the row stayed an unsent draft:
* the editor would open it and the send queue would send it again, and
* discarding it would delete the sent copy at Outlook.
*
* Only Sent Items means "was sent". A draft moved to Trash or back to Drafts is
* still an unsent draft, and those rows are the ones a careless fix breaks.
*/
final class GraphDraftSentElsewhereTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private User $user;
private Account $account;
private const string DRAFTS_FOLDER = 'AAMkAD-drafts';
private const string SENT_FOLDER = 'AAMkAD-sent';
private const string TRASH_FOLDER = 'AAMkAD-trash';
private const string GRAPH_ID = 'AAkALgAAdraft';
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();
$this->account = $this->seedAccount();
$this->seedLabel('Drafts', LabelRole::Drafts, self::DRAFTS_FOLDER);
$this->seedLabel('Sent', LabelRole::Sent, self::SENT_FOLDER);
$this->seedLabel('Trash', LabelRole::Trash, self::TRASH_FOLDER);
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testADraftThatArrivesInSentItemsIsNoLongerADraft(): void
{
$draft = $this->seedDraft();
$before = new DateTimeImmutable();
$this->syncDelta(self::SENT_FOLDER);
$this->em->refresh($draft);
self::assertFalse($draft->isDraft());
self::assertNotNull($draft->sentAt);
self::assertGreaterThanOrEqual($before->getTimestamp(), $draft->sentAt->getTimestamp());
// Presence: the row is still there, and now ranks its thread.
self::assertGreaterThanOrEqual($before->getTimestamp(), $draft->thread?->lastMessageAt?->getTimestamp());
}
/**
* A draft filed in Sent Items before settling existed: it wears the Sent
* label already and is still flagged a draft. Its re-report is no move, but
* settling it is a change, and a change a JMAP client must hear about, so
* the guard that keeps re-reports quiet must not keep this one quiet.
*/
public function testADraftAlreadyFiledInSentIsSettledAndRecordedOnItsReReport(): void
{
$draft = $this->seedDraft();
$draft->removeLabel($this->em->getRepository(Label::class)->findOneBy(['usr' => $this->user, 'role' => LabelRole::Drafts]));
$draft->addLabel($this->em->getRepository(Label::class)->findOneBy(['usr' => $this->user, 'role' => LabelRole::Sent]));
$this->em->flush();
$this->syncDelta(self::SENT_FOLDER);
$this->em->refresh($draft);
self::assertFalse($draft->isDraft());
self::assertSame(1, (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM jmap_change_log WHERE account_id = ? AND object_type = ? AND change_type = ?',
[$this->account->id, 'Email', 'updated'],
), 'the settle is announced, though nothing moved');
}
public function testADraftStillInDraftsStaysADraft(): void
{
$draft = $this->seedDraft();
$this->syncDelta(self::DRAFTS_FOLDER);
$this->em->refresh($draft);
self::assertTrue($draft->isDraft());
self::assertNull($draft->sentAt);
}
/** Thrown away is not sent. */
public function testADraftThatArrivesInTrashStaysADraft(): void
{
$draft = $this->seedDraft();
$this->syncDelta(self::TRASH_FOLDER);
$this->em->refresh($draft);
self::assertTrue($draft->isDraft());
self::assertNull($draft->sentAt);
}
// ── helpers ───────────────────────────────────────────────────────────
/** One folder's delta reporting the stored message in that folder. */
private function syncDelta(string $folderId): void
{
$delta = [
'value' => [[
'id' => self::GRAPH_ID,
'parentFolderId' => $folderId,
]],
'@odata.deltaLink' => 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=new',
];
$container = self::getContainer();
$syncer = new GraphApiSyncer(
new GraphApiClient(
new MockHttpClient([new MockResponse(json_encode($delta, JSON_THROW_ON_ERROR), [
'response_headers' => ['content-type' => 'application/json'],
])]),
$container->get('App\Service\OAuth\OAuthTokenManager'),
),
$container->get('App\Service\Graph\GraphFolderResolver'),
$container->get('App\Service\Graph\GraphLabelPolicy'),
$container->get('App\Repository\Mail\MessageRepository'),
$this->em,
$container->get('App\Jmap\State\StateManager'),
$container->get('messenger.default_bus'),
new \Psr\Log\NullLogger(),
$container->get('App\Service\Mail\MessageEraser'),
$container->get('App\Service\Mail\ThreadStatusUpdater'),
new \App\Service\Mail\SyncOrigin(),
$container->get('App\Service\Label\ThreadLabelSynchronizer'),
new \App\Service\Mail\SyncNotifier(new RecordingHub(), new \Psr\Log\NullLogger()),
);
$this->account->graphDeltaLinks = [
$folderId => 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=old',
];
$syncer->sync($this->account, [$folderId]);
$this->em->flush();
}
/** A draft as the sync stored it: Drafts label, no sentAt, \Draft. */
private function seedDraft(): Message
{
$thread = new MessageThread();
$thread->account = $this->account;
$thread->subject = 'Sent from Outlook';
$thread->normalizedSubject = 'sent from outlook';
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
$thread->lastMessageAt = new DateTimeImmutable('-1 day');
$message = new Message();
$message->account = $this->account;
$message->thread = $thread;
$message->subject = 'Sent from Outlook';
$message->fromAddress = 'graph@example.test';
$message->graphId = self::GRAPH_ID;
$message->receivedAt = new DateTimeImmutable('-1 hour');
$message->hasAttachments = false;
$message->flags = [MessageFlag::DRAFT->value];
$message->syncedAt = new DateTimeImmutable();
$message->addLabel($this->em->getRepository(Label::class)->findOneBy(['usr' => $this->user, 'role' => LabelRole::Drafts]));
$thread->addMessage($message);
$this->em->persist($thread);
$this->em->persist($message);
$this->em->flush();
return $message;
}
private function seedLabel(string $name, LabelRole $role, string $graphFolderId): Label
{
$label = new Label();
$label->usr = $this->user;
$label->name = $name;
$label->role = $role;
$label->isVisible = true;
$this->em->persist($label);
$this->em->flush();
$binding = new LabelBinding();
$binding->label = $label;
$binding->account = $this->account;
$binding->graphFolderId = $graphFolderId;
$label->addBinding($binding);
$this->em->persist($binding);
$this->em->flush();
return $label;
}
private function seedAccount(): Account
{
$account = new Account();
$account->usr = $this->user;
$account->name = 'Graph fixture';
$account->email = 'graph@example.test';
$account->username = uniqid('graph-', true);
$account->imapHost = 'outlook.office365.com';
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->authType = AuthType::OAuth2->value;
$account->oauthProvider = 'microsoft';
$account->oauthAccessToken = 'test-access-token';
$account->oauthTokenExpiry = new DateTimeImmutable('+1 day');
$account->isActive = true;
$this->em->persist($account);
$this->em->flush();
return $account;
}
private function seedUser(): User
{
$user = new User();
$user->email = 'graph-sent-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Graph';
$user->nameLast = 'SentElsewhere';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Imap;
use App\Domain\Helper\ImapConnectionFactory;
use App\Entity\Mail\Mailbox;
use App\Service\Imap\ImapMailboxImporter;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Webklex\PHPIMAP\Message as ImapMessage;
use Webklex\PHPIMAP\Support\FlagCollection;
/**
* An unsent draft has no sentAt, whoever wrote it.
*
* The editor reads "sentAt is set" as "this has gone out" and answers 404, so a
* draft another client left in the Drafts folder, stamped with its Date as
* sentAt on the way in, could be seen in the thread and never opened.
* receivedAt keeps the Date: it is when the row appeared.
*
* webklex strips the backslash, so what a real server's `\Draft` arrives as is
* `Draft`, and the row stores it that way: the app (Message::isDraft) does not
* treat it as a draft until something rewrites the flags, which is separate
* from this. What this pins is that a row is never half of each — "not a draft
* and never sent" — so sentAt follows the stored flag exactly as isDraft()
* reads it.
*
* Through the importer, like ImapMailboxImporterTest, because buildMessage() is
* private and the claim is about what is stored.
*/
final class ImapDraftSentAtTest extends KernelTestCase
{
use SeedsMarkerFixtures;
private Connection $connection;
private ImapMailboxImporter $importer;
private int $mailboxId;
protected function setUp(): void
{
self::bootKernel();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->connection = static::getContainer()->get(Connection::class);
$this->importer = static::getContainer()->get(ImapMailboxImporter::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
$mailbox = new Mailbox();
$mailbox->account = $this->account;
$mailbox->name = 'Drafts';
$mailbox->fullPath = 'Drafts';
$mailbox->isSyncEnabled = true;
$mailbox->lastSeenUid = 3;
$mailbox->importFloorUid = 4;
$this->em->persist($mailbox);
$this->em->flush();
$this->mailboxId = (int) $mailbox->id;
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testSentAtFollowsTheStoredDraftFlag(): void
{
$this->importer->importPage($this->mailboxId, FakeImportClient::holding([
1 => $this->mail(1, ['Draft']),
2 => $this->mail(2, ['\\Draft']),
3 => $this->mail(3, ['Seen']),
]));
// Presence companions: all three were stored, each with its Date as
// receivedAt (Mon, 21 Sep 2026 10:00:00 +0200 = 08:00:00 UTC).
$rows = $this->rows();
self::assertSame([1, 2, 3], array_keys($rows));
foreach ($rows as $uid => $row) {
self::assertSame(
gmmktime(8, 0, 0, 9, 21, 2026),
(new \DateTimeImmutable((string) $row['received_at'], new \DateTimeZone('UTC')))->getTimestamp(),
"uid {$uid} keeps its receivedAt",
);
}
self::assertNull($rows[2]['sent_at'], 'a stored `\\Draft` is an unsent draft');
// Not a draft as the app reads the flag, so not a half-state either.
self::assertNotNull($rows[1]['sent_at'], '`Draft` is not `\\Draft` to isDraft()');
// And the ordinary message is untouched.
self::assertNotNull($rows[3]['sent_at']);
}
/**
* @param list<string> $flags
*/
private function mail(int $uid, array $flags): ImapMessage
{
$raw = "From: Sender <sender@example.test>\r\n"
. "To: {$this->account->email}\r\n"
. "Subject: Draft {$uid}\r\n"
. "Date: Mon, 21 Sep 2026 10:00:00 +0200\r\n"
. "Message-ID: <draft-{$uid}@example.test>\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n\r\nHello.\r\n";
return ImapMessage::fromString($raw, ImapConnectionFactory::config())
->setUid($uid)
->setFlags(FlagCollection::make(array_combine($flags, $flags)));
}
/**
* @return array<int, array<string, mixed>> keyed by IMAP uid
*/
private function rows(): array
{
$rows = [];
foreach ($this->connection->fetchAllAssociative(
'SELECT imap_uid, sent_at, received_at FROM message WHERE mailbox_id = ? ORDER BY imap_uid',
[$this->mailboxId],
) as $row) {
$rows[(int) $row['imap_uid']] = $row;
}
return $rows;
}
}
+25
View File
@@ -234,6 +234,31 @@ final class ReadReceiptTest extends KernelTestCase
);
}
/**
* A draft that asks for a receipt is our own mail on its way out, not a
* request to answer. The policy used to rely on sentAt for this, which a
* synced draft now lacks (it has not been sent), so the draft flag has to
* say it.
*
* The same row without the flag is the presence companion: under "always"
* it is a sendable request, so the silence is the flag's doing.
*/
public function testADraftAsksForAReceiptButIsNeverAnswered(): void
{
$this->setMode(ReadReceiptMode::Always);
$message = $this->receiptRequestedMessage();
$message->flags = [MessageFlag::DRAFT->value];
$this->em->flush();
self::assertFalse($this->policy->decide($message)->isSendable(), 'a draft is not answered');
$message->flags = [];
$this->em->flush();
self::assertTrue($this->policy->decide($message)->isSendable(), 'the same row, not a draft, is');
}
/**
* Re-marking an already-read message read is what the bulk toolbar does
* routinely over a mixed selection. Only the transition may fire.