From 221b509a222dd82f491cd7b89df716cb92bb11b6 Mon Sep 17 00:00:00 2001 From: Smyile <84925446+davidetacchini@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:37:32 +0200 Subject: [PATCH] fix: SMTP command ordering, WebSocket cleanup, inbox keyboard activation - Track EHLO/HELO greeting state in SMTP session; reject MAIL FROM with 503 Bad Sequence if client hasn't greeted yet (M-1) - Track current WebSocket instance and reconnect timer in module scope; close socket and clear timer on component cleanup to prevent leaks during HMR/remount (L-1) - Add onKeyDown handler for Enter/Space on inbox rows so keyboard-only users can activate messages, matching the role="button" semantics (L-2) Closes #16 --- crates/rustmail-smtp/src/session.rs | 15 ++++++++++++--- ui/src/App.tsx | 2 ++ ui/src/components/Inbox.tsx | 6 ++++++ ui/src/stores/messages.ts | 21 ++++++++++++++++++++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/rustmail-smtp/src/session.rs b/crates/rustmail-smtp/src/session.rs index ec38392..81ba36b 100644 --- a/crates/rustmail-smtp/src/session.rs +++ b/crates/rustmail-smtp/src/session.rs @@ -31,6 +31,7 @@ pub struct Session { peer: SocketAddr, sender: mpsc::Sender, max_message_size: usize, + greeted: bool, mail_from: Option, rcpt_to: Vec, } @@ -47,6 +48,7 @@ impl Session { peer, sender, max_message_size, + greeted: false, mail_from: None, rcpt_to: Vec::new(), } @@ -79,15 +81,22 @@ impl Session { let upper = trimmed.to_ascii_uppercase(); if upper.starts_with("EHLO") || upper.starts_with("HELO") { + self.greeted = true; + self.mail_from = None; + self.rcpt_to.clear(); let ehlo = format!( "250-rustmail\r\n250-SIZE {}\r\n250-8BITMIME\r\n250-PIPELINING\r\n250-AUTH PLAIN LOGIN\r\n250 HELP\r\n", self.max_message_size ); self.write(&ehlo).await?; } else if upper.starts_with("MAIL FROM:") { - self.mail_from = Some(extract_address(trimmed)); - self.rcpt_to.clear(); - self.write(OK).await?; + if !self.greeted { + self.write(BAD_SEQUENCE).await?; + } else { + self.mail_from = Some(extract_address(trimmed)); + self.rcpt_to.clear(); + self.write(OK).await?; + } } else if upper.starts_with("RCPT TO:") { if self.rcpt_to.len() >= MAX_RECIPIENTS { self.write("452 Too many recipients\r\n").await?; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 67b410d..c60ae88 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -8,6 +8,7 @@ import ConfirmDialog, { confirm } from "./components/ConfirmDialog"; import { fetchMessages, connectWebSocket, + disconnectWebSocket, filteredMessages, total, selectedId, @@ -129,6 +130,7 @@ export default function App() { onCleanup(() => { document.removeEventListener("keydown", handleKeydown); + disconnectWebSocket(); }); return ( diff --git a/ui/src/components/Inbox.tsx b/ui/src/components/Inbox.tsx index 0d82bed..a29affc 100644 --- a/ui/src/components/Inbox.tsx +++ b/ui/src/components/Inbox.tsx @@ -86,6 +86,12 @@ export default function Inbox() { } } }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + (e.currentTarget as HTMLElement).click(); + } + }} class={`w-full text-left px-4 py-3 border-b border-zinc-100 dark:border-zinc-800/50 transition cursor-pointer ${ isSelected() ? "bg-zinc-100 dark:bg-zinc-800/80" diff --git a/ui/src/stores/messages.ts b/ui/src/stores/messages.ts index 1399884..bd0428c 100644 --- a/ui/src/stores/messages.ts +++ b/ui/src/stores/messages.ts @@ -81,10 +81,15 @@ async function fetchMessages() { let reconnectDelay = 2000; const MAX_RECONNECT_DELAY = 30000; +let currentWs: WebSocket | null = null; +let reconnectTimer: ReturnType | null = null; function connectWebSocket() { + disconnectWebSocket(); + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${protocol}//${location.host}/api/v1/ws`); + currentWs = ws; ws.onopen = () => { reconnectDelay = 2000; @@ -145,14 +150,27 @@ function connectWebSocket() { }; ws.onclose = () => { + currentWs = null; const jitter = reconnectDelay * (0.5 + Math.random() * 0.5); - setTimeout(connectWebSocket, jitter); + reconnectTimer = setTimeout(connectWebSocket, jitter); reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); }; return ws; } +function disconnectWebSocket() { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (currentWs) { + currentWs.onclose = null; + currentWs.close(); + currentWs = null; + } +} + export { messages, filteredMessages, @@ -171,4 +189,5 @@ export { allTags, fetchMessages, connectWebSocket, + disconnectWebSocket, };