Skip to content

fix(compose): keep removed automatic recipients out after undo send - #575

Merged
maathimself merged 6 commits into
maathimself:mainfrom
prudenciooficial:fix/undo-restore-auto-recipients
Oct 9, 2026
Merged

maathimself merged 6 commits into
maathimself:mainfrom
prudenciooficial:fix/undo-restore-auto-recipients

Conversation

@prudenciooficial

@prudenciooficial prudenciooficial commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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.

Before: Undo, then From switched to Sales After: the removed Cc stays out
Before After

Both accounts in the screenshots have records@example.test as 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

  • Undo restores the automatic-recipient state (06406d4): the undo snapshot carries the composer's automatic-recipient state (autoRef.current, as composeData.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.
  • Snapshot taken at Send (056b2cd): autoAtSend is 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.
  • A message autosaved before Send (1fe54b4): it comes back with its draft and was taken for one opened from the Drafts list, so a From switch did not swap its automatic Cc and Bcc. The restore now carries draftWasPreExisting instead of deriving it from draftUid. 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:

    • an automatic Cc removed before sending stays out after Undo, across a From switch and back;
    • after Undo, a From switch replaces A's automatic Bcc with B's;
    • after Undo on a reply, Reply All and back to Reply keeps the automatic Bcc;
    • a From switch made while the message was being sent does not change what it reopens with;
    • after Undo of a message autosaved before Send, a From switch swaps the automatic Cc;
    • once saved again, that message closes without a discard prompt;
    • a draft opened from the list, sent and undone, still gets no automatic Bcc on a From switch.

    The undo tests share one helper, sendAndUndo.

  • Checked in Chromium against a throwaway stack (GreenMail), with main and 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:

  • I have read and agree to the Contributor License Agreement.
  • My contribution is my own original work (or I have identified any
    third-party material and confirmed it is compatible with the CLA).
  • I have the right to submit this contribution under the terms of the CLA.

My English is basic, so this description was written with help from Claude.

🤖 Generated with the help of Claude Code

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 Monkey7539 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's b-arch@. Now it is only b-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.

prudenciooficial and others added 2 commits October 8, 2026 21:37
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>
@prudenciooficial

Copy link
Copy Markdown
Contributor Author

Thank you for the careful review, and for the test.

  • Snapshot at Send: done in 056b2cd as you suggested: autoAtSend is read together with ccFinal and bccFinal. Your test is in, and it fails without the change.
  • The two other cases: now covered. After Undo, a From switch replaces A's automatic Bcc with B's, and on a reply, Reply All and back to Reply keeps the automatic Bcc. Both tests fail with the ComposeModal.jsx from main.
  • The message autosaved before Send: included as a separate commit, 1fe54b4, because I already have feat(compose): reply with AI from the conversation and an instruction #577 open and did not want to open a third PR. The restore now carries draftWasPreExisting instead of deriving it from draftUid. There are two tests. After Undo, a From switch swaps the automatic Cc. And once saved again, the message closes like its composer and keeps its draft, without the discard prompt (the close-prompt change you mentioned). If you or the maintainer prefer it in its own PR, I can drop that commit from here.

The undo tests now share one helper (sendAndUndo), which the test already in this PR uses too. The frontend suite, lint and build pass.

My English is basic, so this reply was written with the help of Claude (Claude Code).

@Monkey7539 Monkey7539 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 autoAtSend goes back to autoRef.current;
  • the two autosave tests fail when draftWasPreExisting comes from draftUid again;
  • 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).

prudenciooficial and others added 2 commits October 9, 2026 12:27
…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>
@prudenciooficial

Copy link
Copy Markdown
Contributor Author

Thank you. Both done:

  1. Your test is in 7ad1387, after the two autosave tests. It fails without the draftWasPreExisting line in the restore and passes with it.
  2. The description now covers all three commits: the snapshot at Send, the autosaved case with its new close behaviour, and the new tests.

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).

@maathimself
maathimself merged commit 5fa5013 into maathimself:main Oct 9, 2026
2 checks passed
Monkey7539 added a commit to Monkey7539/mailflow that referenced this pull request Oct 9, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants