Repository navigation
fix(compose): keep removed automatic recipients out after undo send - #575
maathimself merged 6 commits into
Conversation
A message reopened by undo send started with no record of the automatic Cc or Bcc the user had removed before sending, so switching From to an account whose list has that address brought it back, and switching back kept it. The restore now carries the composer's automatic-recipient state, so the reopened message behaves as its composer would have had it stayed open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Monkey7539
left a comment
There was a problem hiding this comment.
Reviewed 06406d4 on main 8b5c445.
The bug reproduces on main. With the ComposeModal.jsx change reverted, the new test fails at "a From switch does not bring it back" (crm@example.invalid comes back). With the change, it passes. The frontend suite (2642), lint and the build pass too.
The approach is right. The restore carries the whole autoRef.current, not only the removed addresses, so the reopened composer continues the original composer's bookkeeping. That also fixes two cases the PR does not mention. I checked both against main:
- After Undo, switching From to an account with a different list kept the old account's automatic address: Bcc was A's
a-arch@plus B'sb-arch@. Now it is onlyb-arch@. - After Undo on a reply, switching to Reply All and back to Reply dropped the automatic Bcc. Now it stays.
No test covers these two. The new test uses the same address on both accounts, so it checks suppressed, but not placed or lists.
One small change: take the snapshot when Send is clicked. autoRef.current is read after await api.post(...) returns. Everything else in the restore (accountId, to, cc, bcc, subject, quoted HTML) comes from the moment of the click. The From menu stays enabled while sending. So if the user switches From to B during a slow upload, the snapshot describes B, but the reopened message is A's. After Undo, switching to B keeps A's automatic Cc and never adds B's, because B's address is recorded as removed by the user.
const ccFinal = [...ccChips, ...(ccInput.trim() ? [ccInput.trim()] : [])];
const bccFinal = [...bccChips, ...(bccInput.trim() ? [bccInput.trim()] : [])];
+ // Read with the recipients it describes: From stays usable while the request is out.
+ const autoAtSend = autoRef.current;
@@
restored: true,
- autoRecipients: autoRef.current,
+ autoRecipients: autoAtSend,Test: fails on 06406d4 and on main, passes with the change (render file 39/39)
Goes in the automatic Cc and Bcc (#491) block, after the new test.
test('after undo send, a From switch made while the message was being sent does not change what it reopens with', async () => {
const accounts = [account('A', { cc: ['crm-a@example.invalid'] }), account('B', { cc: ['crm-b@example.invalid'] })];
const Host = () => {
const composing = useStore(s => s.composing);
const session = useStore(s => s.composeSession);
return composing ? React.createElement(ComposeModal, { key: session }) : null;
};
const plainPost = api.post;
let answer;
api.post = (path, body) => {
posted.push({ path, body });
return new Promise(resolve => {
answer = () => resolve({ ok: true, pending: true, pendingId: 'p-auto-2', sendAt: new Date(Date.now() + 10_000).toISOString(), remainingMs: 10_000 });
});
};
api.cancelSend = async () => ({ cancelled: true });
saved.length = 0;
posted.length = 0;
useStore.setState({ plaintextEmail: false, accounts, selectedAccountId: 'A', defaultSender: '', notifications: [], composing: false, composeData: null });
useStore.getState().openCompose({ to: ['x@example.invalid'], subject: 'Plan' });
const root = createRoot(document.getElementById('root'));
await React.act(async () => { root.render(React.createElement(Host)); });
await React.act(async () => {});
const cc = () => chips('compose.cc') ?? [];
try {
await send();
// The server has not answered yet, and From stays usable while it is sending.
await chooseFrom('account:B');
await React.act(async () => { answer(); });
await React.act(async () => {});
const bar = useStore.getState().notifications.find(n => n.onUndo);
await React.act(async () => { await bar.onUndo(); });
await React.act(async () => {});
assert.deepEqual(cc(), ['crm-a@example.invalid'], 'reopened as it was sent, from A');
await chooseFrom('account:B');
assert.deepEqual(cc(), ['crm-b@example.invalid'], 'B\'s automatic Cc replaces A\'s');
} finally {
api.post = plainPost;
await React.act(async () => root.unmount());
}
});Not for this PR, but related. This covers a message with no draft, as the note on #533 did. A message autosaved before Send is different. That is any composer open longer than 15 to 30 seconds, or one whose tab was hidden. It comes back with draftUid set, so draftWasPreExisting is true and the From-switch effect returns early. After Undo, switching A to B keeps A's automatic Bcc and does not add B's. The original composer would have swapped them. Main does the same, so this is not a regression. But the commit message says the reopened message "behaves as its composer would have had it stayed open", and that is not true in this case. In a quick check, carrying draftWasPreExisting.current through the restore (instead of deriving it from draftUid) fixes it. It also changes the close prompt for those messages, though, so it is probably its own PR.
Merge. The PR is on current main and has no conflicts. #538 merges cleanly on top of it, and the frontend suite passes with both (2724). #556 and #439 already conflict with main in ComposeModal.jsx; this PR adds no new conflict to either.
The automatic Cc and Bcc state given back by Undo was read after the send request returned, while everything else in the restore comes from the moment Send was clicked. From stays usable while the request is out, so a switch to another account during a slow upload left the reopened message, still from the first account, with the second account's state: switching to that account again then kept the first account's automatic Cc and never added its own. Taken at the click now, with the recipients it describes. Also covers two cases the restore already fixed, which no test held: after Undo, a From switch replaces the first account's automatic Bcc with the second's instead of adding to it, and on a reply, Reply All and back to Reply keeps the automatic Bcc. Reported in review by Monkey7539. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A composer open for 15 to 30 seconds, or whose tab was hidden, has saved a draft by the time Send is clicked. Undo gives the message back with that draft, so the reopened composer took it for one opened from the Drafts list: a From switch then kept the first account's automatic Cc and Bcc and did not add the second's, which the original composer would have swapped. The reopened composer now keeps what its composer had instead of deriving it from the draft. That also changes how it closes once saved again: like the composer it came from, it closes and keeps its draft, without the discard prompt a draft opened from the list gets. Noted in review by Monkey7539. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thank you for the careful review, and for the test.
The undo tests now share one helper ( My English is basic, so this reply was written with the help of Claude (Claude Code). |
Monkey7539
left a comment
There was a problem hiding this comment.
Thank you, this looks good. I re-checked 1fe54b4 on its own and merged with main at aab3ade, which now includes #538. Since then main has only added #574, which is backend only.
All three points are done, and each new test fails without its change:
- the in-flight test fails when
autoAtSendgoes back toautoRef.current; - the two autosave tests fail when
draftWasPreExistingcomes fromdraftUidagain; - all six fail with main's
ComposeModal.jsx.
My earlier checks now all behave like a fresh composer. That includes an autosaved message whose automatic Cc was removed before Send. With this branch merged into current main, the frontend suite (2730), lint and the build pass.
Keeping 1fe54b4 in this PR is fine with me: it is the same fix for the autosaved case, in its own commit. Two small things:
1. One more test. If draftWasPreExisting: draftWasPreExisting.current is deleted from the restore, no test fails. Without that line, a draft opened from the Drafts list that is sent and then undone gets the automatic Bcc of the account From is switched to, and #491 rules that out for reopened drafts. This test fails without the line and passes with it:
test('after undo send of a draft opened from the list, a From switch still adds no automatic Bcc', async () => {
// The other side of the two tests above: this one was opened from a draft, so it stays one.
const accounts = [account('A', { bcc: ['a-arch@example.invalid'] }), account('B', { bcc: ['b-arch@example.invalid'] })];
const close = await sendAndUndo(
{ accountId: 'A', draftUid: 7, draftFolder: 'Drafts', to: ['x@example.invalid'], subject: 'Plan', body: '<p>Plan</p>' },
{ accounts },
);
try {
assert.equal(useStore.getState().composeData.draftUid, 7, 'reopened with its draft');
assert.equal(chips('compose.bcc'), null, 'no Bcc row: the draft saved none');
await chooseFrom('account:B');
assert.equal(chips('compose.bcc'), null, 'nor from the account From switches to');
} finally { await close(); }
});2. The PR description still describes only the first commit, so the maintainer would see the close-prompt change only in a commit message. Could you add three things to it: the snapshot at Send, the autosaved case with its new close behaviour, and the new tests?
Merge. The branch is behind main, which now has #538 (and #574, backend only). It merges without conflicts, but the CI run on 1fe54b4 finished before #538 landed. It adds no new conflicts to the other open PRs that touch ComposeModal.jsx (#577, #556, #439).
…a From switch The restore carries whether the composer was opened from a draft. Without it, a draft opened from the Drafts list, sent and undone was given the automatic Bcc of the account From was switched to, which maathimself#491 rules out for reopened drafts, and no test noticed. Test from review by Monkey7539. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ix/undo-restore-auto-recipients
|
Thank you. Both done:
I also merged current main into the branch (3203f48), so CI runs again on top of #538 and #574. No conflicts; the frontend suite (2731), lint and build pass. My English is basic, so this reply was written with the help of Claude (Claude Code). |
Brings in v3.9.0 and the seven commits after it: admin last-seen and user editing (maathimself#534), reply-draft handoffs (e108a5d, 2c98b9a, 1063401), the HTML5 sanitizer pass (maathimself#574 and its three follow-ups), the signature toggle (maathimself#556), the Discard button (maathimself#573), undo send's automatic recipients (maathimself#575), the text part of rich-text mail (4b4de50), the next message after removing the open one (maathimself#572), drafts kept apart from their conversation and archive scoped to the viewed folder (108ff84, 14465ea, maathimself#576), forwarded messages as one .eml (0681fd1, c0113ec), maathimself#579, and our maathimself#569, maathimself#570 and maathimself#571. Lock screen: App.jsx and App.render.test.js are upstream's (maathimself#570 was ours). LockScreen keeps dev's call to store.signOut, which carries the same keep-the-lock rule; upstream's two sign-out tests pass against it. Compose: maathimself#573 and the composeSwitch machinery make Send and Discard wait for a save in flight and act on the newest copy, which is what dev's e8391b2 (draftCopyRef, draftSaveRef, deleteDraftCopy) and its port into the held send did. Took upstream's; dev's render tests for it were rewritten to the merged behaviour, and the one that only encoded dev's ordering was dropped. The signature contenteditable keeps dev's onPaste. Dev's AI sender note now follows the signature toggle: with the signature off it no longer tells the model a signature will follow, so the model signs off instead (two render tests). Conversation and list: upstream's shape (anchorId, folder, accountId; keepDraftsApart; inArchiveScope; maathimself#572's advance in runAction) with dev's semantics on top: the pane's delete is scoped like archive, its move takes the row's account with the row as fallback, the picker keeps its row, ReadingPane passes a scope for a message that is not a list row, and the list's single and multi-select deletes run keepDraftsApart before archiveTargetsForFolder. A `row` option and anchorOf cover a Gmail label folder whose thread holds only the INBOX twin. Dev's threadActions tests only changed their option names. imapManager: upstream's one-.eml model for attached messages (isAttachedMessage, pushAttachedMessage, `contains`), which the antispam rules now read; dev's attachedEmail rule, its multipart/report case and the inner files listed separately go. Dev's inline-image listing (listUnreferencedInlineImages, findPart, the inlineImages fields, fetchAttachment's encoding) stays. Dev's tests for the dropped model were rewritten to `contains` or removed. Store: setUser's identity-change reset gains upstream's new per-user state (replyDrafts, prepareComposeSwitch, autoOpenReplyDrafts, afterRemove), and signOutState.test.js covers them. replyDraftRevision is left alone: lookups in flight stop on the user id. Two upstream tests assumed an open composer survives an identity change; dev closes it, so they now assert that nothing is open (InboxReplyDraftObserver and ComposeModal.replyDraft tests). Everything else merged clean; websocket.js and index.js are identical to upstream's now that maathimself#569 is in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
The follow-up you noted on #533. After Undo on a message with no draft, switching From to another account whose automatic Cc includes an address the user removed before sending brought it back, and switching back kept it. A fresh composer keeps a removed address out across From changes. The reopened one now does too.
Both accounts in the screenshots have
records@example.testas an automatic Cc. It was removed before sending from Finance.Review by @Monkey7539 found two more cases, now fixed here as well: the snapshot was read after the send request returned instead of at the click, and a message autosaved before Send came back treated as a draft opened from the list.
Changes
autoRef.current, ascomposeData.autoRecipients), and a message reopened by undo starts from it instead of an empty one. Besides the case above, a From switch after Undo replaces the first account's automatic Bcc with the second's instead of adding to it, and on a reply, Reply All and back to Reply keeps the automatic Bcc.autoAtSendis read together with the recipients it describes. From stays usable while the request is out, so a From switch during a slow upload no longer gives the reopened message the other account's state.draftWasPreExistinginstead of deriving it fromdraftUid. This changes how it closes: once saved again, it closes and keeps its draft like the composer it came from, without the discard prompt a draft opened from the list gets. A draft that was opened from the list stays one after Undo.Testing
Render tests, each failing without its change; the first six also fail with main's
ComposeModal.jsx:The undo tests share one helper,
sendAndUndo.Checked in Chromium against a throwaway stack (GreenMail), with
mainand with the first commit; those are the screenshots above.Merged with current main: frontend suite (2731), lint and build pass.
Contributor License Agreement
By submitting this pull request I confirm that:
third-party material and confirmed it is compatible with the CLA).
My English is basic, so this description was written with help from Claude.
🤖 Generated with the help of Claude Code