Repository navigation
Conversation
…s in the editor The editor, the send queue and the thread read "sentAt is set" as "has gone out". The Graph, Gmail and IMAP builders stamped every synced message with sentAt (Outlook: sentDateTime ?? receivedDateTime, which a draft has), so a draft written in another client was listed in the thread and then refused by the editor with a 404 (ComposeController::assertEditableDraft). The builders now leave sentAt null for a draft, the same state a draft written here has. receivedAt is unchanged. IMAP compares the flag canonically, since webklex reports `Draft` from some servers and `\Draft` from others. ReadReceiptPolicy::decide used "has sentAt" to recognise our own mail; a draft no longer has one, so it checks the draft flag as well. Visible change: MessageThreader skips an unsent draft when moving a thread's lastMessageAt, so a synced draft no longer floats its thread up the list. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…raft() reads it webklex strips the backslash from a server's flags, so a real `\Draft` is stored as `Draft` and is not a draft to Message::isDraft(). Matching it canonically gave a row that was neither a draft nor sent. sentAt now follows the stored flag exactly, and the changelog no longer claims IMAP drafts. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sync stores a draft once and never rebuilds a stored row, so sending it
elsewhere left it flagged \Draft with no sentAt. Now that a synced draft has no
sentAt the editor opens it (it used to 404, which was an accidental guard), so
the stale row could be opened, sent a second time, and discarded, which deletes
the sent copy at the provider.
Message::settleAsSent() is the one place that says what "was sent" means for a
stored draft: flag off, sentAt set, thread ranked by it. It is called when
- Graph files a stored message in Sent Items (same id, immutable ids),
- the Graph batch handler re-points a stored message whose fetched resource
says isDraft = false (new id, no immutable ids),
- Gmail enrichment re-reads a stored draft whose labels no longer include
DRAFT (before the flag refresh).
Trash and Drafts do not settle: a draft thrown away or put back is unsent.
RemoteFlagState::storedFlags() rebuilt Gmail's and Graph's flag mirror from the
read and starred facts alone, which dropped \Draft the first time a stored draft
was re-read, despite the refresh's own comment saying it survives. It now
carries the flag over.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…sent Also drops three needless nullsafe calls from the settle test (phpstan). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…w what counts as sent Review of the settle change: - A draft stored before synced drafts were kept unsent already has a sentAt. settleAsSent() ranked the thread by "now" instead, so the mass re-read an upgrade causes would float every old conversation. It keeps its sentAt and ranks by it. - Gmail settled on "DRAFT label absent"; Graph only on Sent Items. Gmail now needs SENT present and DRAFT absent. - The Graph handler settled on an absent isDraft; only an explicit false counts. - Comments that overstated (JMAP announcement of the move, "a delta never changes draft state") corrected. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Owner
|
Thanks, @Flouss! Your fork has been archived, so this branch can no longer be pushed to. Your work goes on in #164, which keeps your commits, merges main, and adds a migration for drafts stored before the upgrade (only ones the provider still files as drafts). You'll be credited as co-author when it's merged. Closing this one in its favour. |
karatektus
added a commit
that referenced
this pull request
Oct 15, 2026
…t 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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #135
Problem
A draft that arrives by sync (Outlook or Gmail) is listed in its conversation, but clicking it gives a 404:
ComposeController::assertEditableDraft(the guard that only lets an unsent draft open) refuses anything with asentAt, and every sync builder setssentAton a draft:sentAt = sentDateTime ?? receivedDateTime, and Outlook gives a draft areceivedDateTimebut nosentDateTime.sentAt = receivedAtfrominternalDate.sentAt = receivedAtfrom the Date header.Change
sentAtnull for a draft, the same state a draft written in plMail has (receivedAtis unchanged). Documented onMessage::$sentAt. The IMAP builder decides from the stored flag viaisDraft(); webklex strips the backslash, so a real server's\Draftis stored asDraftand is not a draft to the app yet. That part does nothing until the flags are canonicalised at ingest, and is deliberately kept consistent withisDraft()instead.ReadReceiptPolicy::deciderecognised our own mail bysentAt; it checks the draft flag as well now.sentAtwas also quietly protecting the other direction, which this PR has to handle: sync stores a draft once and never rebuilds a stored row, so a draft sent from another client stayed\Draftwith nosentAtand would now open in the editor, be sendable a second time, and discarding it would delete the sent copy at the provider.Message::settleAsSent()is the one place that says what "was sent" means for a stored draft (flag off, keep an existingsentAtelse now, thread ranked by it, never backwards). It is called whenisDraft: false(new id, no immutable ids; an absent key is not an answer),SENTand noDRAFT.Trash and Drafts do not settle.
RemoteFlagState::storedFlags()rebuilt Gmail's and Graph's flag mirror from read and starred alone, which dropped\Draftthe first time a stored draft was re-read, although the refresh's own comment says it survives. It now carries the flag over. This touches the shared flag refresh, so it deserves a look.Notes
sent_athas to be cleared once (a singleUPDATEon the affected rows).isDraft: false, and Gmail keeps the message id withDRAFTreplaced bySENT.Tests
Builder tests for all three providers, the sent-elsewhere paths (Graph same id, Graph new id, Gmail) each with a companion that must stay a draft, the settle method, the flag mirror, the editor opening a synced draft and still refusing a received mail, and the read-receipt guard. Each behaviour was broken on purpose and the test written for it failed. Full suite green except
IntegrationUrlValidatorTest(container cannot resolvelocalhost.); phpstan clean on the changed files.🤖 Generated with Claude Code