Skip to content

[Bug]: Global keyboard shortcuts fire while editing inside the QuotedHtml shadow DOM — Delete/Backspace permanently deletes the original email #654

Description

@ivikssia

Description

Summary

When editing the quoted original inside a reply/forward (the QuotedHtml
"quote island" introduced in 1.7.3), global mailbox shortcuts still fire.
Pressing Delete or Backspace while redacting quoted text deletes the
currently open email from the mailbox instead of deleting text. Other
single-key shortcuts (s, e, r, f, c, j, k, …) are also
intercepted with preventDefault(), which makes typing inside the quote
appear broken — the keystrokes are swallowed as mailbox commands.

Reproduced on 1.7.7 (current main).

Root cause

components/email/quoted-html.ts renders the quote inside a Shadow Root
with an inner contentEditable div. As the file's own comment notes,
document.activeElement is retargeted to the host element inside a shadow
root. The NodeView tracks focus locally via focusin/focusout, but that
state is never exposed to the shortcut layer.

hooks/use-keyboard-shortcuts.tsisInputFocused() only inspects
document.activeElement (tag name + contenteditable attribute). The
shadow host is a plain div, so the check returns false and the global
keydown handler treats the user as not typing. With an email open,
hasBatchTarget is true, so #/Delete/Backspace invoke onDelete().

Note that physicalShortcutKey() derives keys from event.code, so
non-Latin/regional layouts do not avoid the bug.

Fix

Replace the document.activeElement check with one based on
event.composedPath(), which sees through the shadow boundary:

function isTypingEvent(event: KeyboardEvent): boolean {
  return event.composedPath().some((node) => {
    if (!(node instanceof HTMLElement)) return false;
    const tag = node.tagName.toLowerCase();
    return (
      tag === "input" ||
      tag === "textarea" ||
      tag === "select" ||
      node.isContentEditable ||
      node.getAttribute("contenteditable") === "true"
    );
  });
}

and in the keydown handler:

if (isTypingEvent(event)) return;   // was: if (isInputFocused()) return;

Additionally I removed case "delete": / case "backspace": from the
delete action, keeping only the documented # shortcut. A single
unmodified key that irreversibly deletes mail (with permanent-delete
enabled) is dangerous defense-in-depth-wise even after the focus fix —
any future focus-detection regression would again cause silent data loss.
I understand this part is opinionated; the composedPath() fix alone
resolves the reported bug.

Steps to Reproduce

Steps to reproduce

  1. Open any received email.
  2. Press Forward.
  3. Click inside the quoted original (the QuotedHtml island).
  4. Press s → the letter is not typed; the original email's star toggles.
  5. Press Backspace → the original email is deleted from the mailbox.
    With "Delete action: permanently delete" enabled, this is unrecoverable
    data loss triggered by normal text editing.

Expected Behavior

While the cursor is inside the editable quoted original (the QuotedHtml
shadow-DOM island) in a reply/forward, keystrokes should behave as normal
text editing:

  • Letter keys insert text.
  • Backspace/Delete remove the selected text or the character next to
    the caret — and never touch the mailbox.
  • Global mailbox shortcuts (s, e, r, f, c, j, k, #, …)
    are suppressed, exactly as they already are for regular inputs,
    textareas, and the main TipTap editor body.

Actual Behavior

The global shortcut layer does not recognize the shadow-DOM
contentEditable as a typing context, so every single-key shortcut fires
with preventDefault():

  • Letters are swallowed instead of typed (s toggles the original
    email's star, j/k move list selection, c opens compose, …), so
    editing the quote appears broken.
  • Backspace/Delete invoke the mailbox delete action on the currently
    open email. With "Delete action: permanently delete" enabled this is
    unrecoverable data loss caused by ordinary text editing.

Affects all keyboard layouts, since physicalShortcutKey() maps from
event.code. Reproduced on 1.7.7 (current main).

Bulwark Version

1.7.7.

Stalwart Mail Server Version

0.16.13

Browser

Chrome / Chromium

Operating System

Windows

Screenshots / Screen Recording

No response

Relevant Logs or Error Output

Additional Context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions