mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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:
22 files changed
+1958
-14
No files matched your search
+14
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user