Merge an unclaimed Gmail message into another account's copy

72d3956b keeps a message for the Gmail account when nothing else claims
it. One case makes that a duplicate: Gmail collected the message from
another connected account (Gmailify or "check mail from other
accounts"), that account's own sync holds it, and none of its address
survived in the headers — a Bcc'd newsletter, say. The old rule dropped
it; the new one would have stored it a second time.

When a message reaches the Gmail account only because nobody claimed
it, the user's other accounts are asked for the same Message-ID first,
and a copy found there is enriched (gmailId, labels) as a Gmailify
sibling's is. A message whose headers name the Gmail account itself is
not looked up.

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

No files matched your search

@@ -243,6 +243,27 @@ final readonly class SyncGmailMessageBatchHandler
$nowStored[] = $gmailId;
}
// ── Claimed by nobody: is it another account's copy? ──────────────
// A message whose headers name neither this account nor any other
// is this account's — it is in its mailbox. But Gmail may have
// fetched it from another connected account whose own sync holds
// it already: a newsletter Bcc'd to that address, collected by
// Gmail with nothing of the address left in its headers. Stored
// here as well, it would be listed twice. So before it is taken
// as this account's, the user's other accounts are asked for the
// same Message-ID, and a copy found there is enriched instead,
// exactly as a Gmailify sibling's is below.
if ($targetAccount === $account && false === $this->namesCarrier($labelIds, $headers, $account)) {
$copy = $this->copyOnAnotherAccount($headers, $siblingAccounts);
if (null !== $copy) {
$this->enrichExisting($copy, $labelIds, $gmailId, $copy->account, $account, false);
$enriched++;
$affectedAccounts[(int) $copy->account->id] = $copy->account;
continue;
}
}
// ── Gmailify dedup: merge, don't skip ─────────────────────────────
// When the sibling's own IMAP sync already holds this message
// (same canonical RFC Message-ID), the IMAP row keeps ownership of
@@ -606,6 +627,56 @@ final readonly class SyncGmailMessageBatchHandler
return $account;
}
/**
* Whether the headers themselves tie the message to this account, as
* opposed to its having landed here because nobody else claimed it.
*
* @param list<string> $labelIds
* @param array<string,string> $headers
*/
private function namesCarrier(array $labelIds, array $headers, Account $account): bool
{
$written = true === in_array('SENT', $labelIds, true) || true === in_array('DRAFT', $labelIds, true);
return true === $written
? $this->addressFilter->isSentByAccount($headers, $account)
: $this->addressFilter->isAddressedToAccount($headers, $account);
}
/**
* The same message already stored on another of the user's accounts, by
* RFC Message-ID, or null.
*
* @param array<string,string> $headers
* @param array<string,Account> $siblingAccounts normalisedEmail → Account; one account can appear under several keys
*/
private function copyOnAnotherAccount(array $headers, array $siblingAccounts): ?Message
{
$rfcMessageId = MessageIdHelper::normalise($headers['message-id'] ?? '');
if ('' === $rfcMessageId) {
return null;
}
$asked = [];
foreach ($siblingAccounts as $sibling) {
if (true === isset($asked[(int) $sibling->id])) {
continue;
}
$asked[(int) $sibling->id] = true;
$existing = $this->messageRepository->findOneForAccountByMessageId($sibling, $rfcMessageId);
if (null !== $existing) {
return $existing;
}
}
return null;
}
/**
* Build a map of normalised email address → Account for all active
* accounts belonging to the same user as $account, excluding $account itself.
@@ -180,6 +180,57 @@ final class GmailSkippedMessageTest extends KernelTestCase
self::assertNotSame($filter->normalise('karatektus@gmail.com'), $filter->normalise('karatektus@googlemail.de'));
}
/**
* The duplicate the new rule could otherwise make. Gmail collected this
* from another connected account — a Bcc'd newsletter, with nothing of
* that address left in its headers — and that account's own sync holds
* it already. It names nobody, so it would have been stored here as
* well; it is merged into the copy that exists instead.
*/
public function testAnUnclaimedMessageAnotherAccountAlreadyHoldsIsMergedNotStoredTwice(): void
{
$sibling = new Account();
$sibling->usr = $this->user;
$sibling->name = 'Work';
$sibling->email = 'me@work.test';
$sibling->username = uniqid('work-', true);
$sibling->authType = AuthType::Password->value;
$sibling->imapHost = 'imap.work.test';
$sibling->isActive = true;
$this->em->persist($sibling);
// On a conversation of its own account, as every stored message is:
// that is how a copy is found (findOneForAccountByMessageId).
$thread = new \App\Entity\Mail\MessageThread();
$thread->account = $sibling;
$thread->subject = 'Newsletter';
$thread->normalizedSubject = 'newsletter';
$thread->threadingMethod = \App\Domain\Enum\Mail\ThreadingMethod::SubjectFallback;
$thread->lastMessageAt = new DateTimeImmutable('-1 hour');
$thread->unreadCount = 0;
$this->em->persist($thread);
$copy = new Message();
$copy->account = $sibling;
$copy->thread = $thread;
$copy->subject = 'Newsletter';
$copy->fromAddress = 'news@list.test';
$copy->messageId = 'gmail-skipped-1@example.test';
$copy->receivedAt = new DateTimeImmutable('-1 hour');
$copy->hasAttachments = false;
$copy->flags = [];
$this->em->persist($copy);
$this->em->flush();
$this->handle(['INBOX'], to: 'list-members@list.test', from: 'news@list.test');
self::assertSame(0, $this->storedCount(), 'not a second copy on the Gmail account');
$this->em->refresh($copy);
self::assertSame(self::GMAIL_ID, $copy->gmailId, 'the copy that exists learns its Gmail id instead');
}
/** A Gmail draft is a draft here: in Drafts, flagged as one. */
public function testAGmailDraftIsStoredAsADraft(): void
{