diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx
index b811d25ef..343b73501 100644
--- a/docs/content/docs/api/endpoints.mdx
+++ b/docs/content/docs/api/endpoints.mdx
@@ -229,7 +229,7 @@ The `/unibox/drafts` endpoints hold autosaved compose drafts, scoped to the call
`PATCH /emails/:id` accepts `save_to_sent` (boolean) on SMTP/IMAP mailboxes: when true, which is the default, the worker files a copy of each outbound message in the mailbox's Sent folder. It has no effect on Gmail and Outlook mailboxes, whose APIs file their own copy. See [keeping a copy of sent mail](/guides/mailboxes/#keeping-a-copy-of-sent-mail).
-`GET /unibox` and `GET /unibox/thread` return message previews: each row carries `snippet`, a one-line summary, not the message body. Read a full message with `GET /unibox/:id`, which returns `body_plain` plus `body_html`. The HTML is sanitized before it leaves the API (scripts, event handlers, embedded frames, and unsafe URL schemes are removed), so it is safe to render, and links carry `target="_blank"` with `rel="noopener"`. `body_truncated` is `true` on the rare message whose stored body could not be read, where `body_plain` falls back to the snippet.
+`GET /unibox` and `GET /unibox/thread` return message previews: each row carries `snippet`, a one-line summary, not the message body. Read a full message with `GET /unibox/:id`, which returns `body_plain` plus `body_html`. The HTML is sanitized before it leaves the API (scripts, event handlers, embedded frames, and unsafe URL schemes are removed), so it is safe to render, and links carry `target="_blank"` with `rel="noopener"`. Warmbly open-tracking pixels are also removed from this display copy, including quoted history, so rendering it does not record a campaign open. Stored and delivered copies keep their pixels. `body_truncated` is `true` on the rare message whose stored body could not be read, where `body_plain` falls back to the snippet.
`GET /unibox` also accepts `address= Quoted reply Hello
`,
+ `
`,
+ `
`,
+ `
`,
+ `
`,
+ } {
+ t.Run(markup, func(t *testing.T) {
+ out := Sanitize(`Hello
café & more
Visit`)
+ for _, want := range []string{image, link, "data:image/png;base64,iVBORw0KGgo=", "/t/o/logo.png", "café & more"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("display content %q lost: %s", want, out)
+ }
+ }
+ if again := Sanitize(out); again != out {
+ t.Errorf("sanitizing twice changed the display: %s", again)
+ }
+}
diff --git a/internal/tasks/tracking_links_test.go b/internal/tasks/tracking_links_test.go
index 52f668279..943fbacc2 100644
--- a/internal/tasks/tracking_links_test.go
+++ b/internal/tasks/tracking_links_test.go
@@ -6,8 +6,28 @@ import (
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
+ "github.com/warmbly/warmbly/internal/pkg/mailhtml"
)
+func TestOpenTrackingIsRemovedOnlyFromDisplayCopy(t *testing.T) {
+ const original = `Hello
`
+ for _, host := range []string{"t.warmbly.com", "custom.example.com", "localhost:3000"} {
+ t.Run(host, func(t *testing.T) {
+ delivered := AddOpenTrackingPixel(original, uuid.New(), host)
+ displayed := mailhtml.Sanitize(delivered)
+ if strings.Contains(displayed, "/t/o/") {
+ t.Fatalf("reading the sent copy would track an open: %s", displayed)
+ }
+ if !strings.Contains(delivered, "/t/o/") {
+ t.Fatalf("recipient copy lost tracking: %s", delivered)
+ }
+ if !strings.Contains(displayed, "https://example.com/logo.png") {
+ t.Fatalf("ordinary image lost: %s", displayed)
+ }
+ })
+ }
+}
+
func TestAddOpenTrackingPixelUsesTheConfiguredHost(t *testing.T) {
html := "hi"
out := AddOpenTrackingPixel(html, uuid.New(), "t.acme.com")
diff --git a/web/src/app/app/unibox/page.tsx b/web/src/app/app/unibox/page.tsx
index 12cc9cd13..6bd900c55 100644
--- a/web/src/app/app/unibox/page.tsx
+++ b/web/src/app/app/unibox/page.tsx
@@ -78,7 +78,7 @@ export default function UniboxPage() {
// opaque mailbox/tag/label id for those scopes) is the only query param left.
// Accounts are no longer in the URL: the thread fetch scans every mailbox the
// user owns, which is the right default for a unified inbox.
- const urlScope = routeParams.scope ?? "all";
+ const urlScope = routeParams.scope ?? "inbox";
const urlThread = routeParams.threadId ?? null;
const urlScopeRef = searchParams.get("ref");
diff --git a/web/src/app/app/unibox/uniboxKeyboard.test.tsx b/web/src/app/app/unibox/uniboxKeyboard.test.tsx
index 4ce900f76..20c012b2a 100644
--- a/web/src/app/app/unibox/uniboxKeyboard.test.tsx
+++ b/web/src/app/app/unibox/uniboxKeyboard.test.tsx
@@ -9,7 +9,7 @@
import React from "react";
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
-import { screen, act, fireEvent } from "@testing-library/react";
+import { screen, act, fireEvent, waitFor } from "@testing-library/react";
import { useAppStore } from "@/stores";
import { visibleShortcuts } from "@/hooks/useKeyboardShortcuts";
import {
@@ -22,6 +22,8 @@ import {
SUITE,
} from "./uniboxHarness";
+const searchRequests = vi.hoisted((): string[] => []);
+
beforeAll(() => {
installLayoutShims();
setViewportWidth(1512);
@@ -29,6 +31,9 @@ beforeAll(() => {
vi.mock("@/lib/api/client/Request", () => ({
default: async (cfg: { url?: string }) => {
+ if (cfg.url === "/unibox" || cfg.url?.startsWith("/unibox?")) {
+ searchRequests.push(cfg.url);
+ }
const { route } = await import("./uniboxHarness");
return route(String(cfg?.url ?? ""));
},
@@ -71,11 +76,28 @@ const selected = () => useAppStore.getState().selectedThreadId;
describe("unibox list shortcuts (#484)", SUITE, () => {
beforeEach(() => {
+ searchRequests.length = 0;
resetScrollTops();
useAppStore.setState({ selectedThreadId: null, navCollapsed: false });
useAppStore.getState().clearSequence();
});
+ it("opens Inbox from the main navigation and keeps Sent and All mail explicit", async () => {
+ const router = await mount("/app/unibox");
+ await waitFor(() => expect(searchRequests.length).toBeGreaterThan(0));
+ expect(searchRequests.every((url) => new URL(url, "https://test.local").searchParams.get("folder") === "inbox")).toBe(true);
+
+ searchRequests.length = 0;
+ await act(async () => { await router.navigate("/app/unibox/sent"); });
+ await waitFor(() => expect(searchRequests.length).toBeGreaterThan(0));
+ expect(searchRequests.every((url) => new URL(url, "https://test.local").searchParams.get("folder") === "sent")).toBe(true);
+
+ searchRequests.length = 0;
+ await act(async () => { await router.navigate("/app/unibox/all"); });
+ await waitFor(() => expect(searchRequests.length).toBeGreaterThan(0));
+ expect(searchRequests.every((url) => !new URL(url, "https://test.local").searchParams.has("folder"))).toBe(true);
+ });
+
it("moves, jumps to the ends, opens and deselects", async () => {
await mount("/app/unibox/all");
await settle();
diff --git a/web/src/components/app/unibox/ConversationList.tsx b/web/src/components/app/unibox/ConversationList.tsx
index d424845db..ef7f8bd76 100644
--- a/web/src/components/app/unibox/ConversationList.tsx
+++ b/web/src/components/app/unibox/ConversationList.tsx
@@ -108,12 +108,12 @@ export function ConversationList({
return next;
}, [params, debouncedSearch]);
- const q = useUniboxSearch(merged);
+ const q = useUniboxSearch(merged, scopeKey);
const emails = q.emails;
const totalShown = emails.length;
const activeFilters = countUserFilters(params, baseParams);
- // A scope, search or filter change keeps the previous rows on screen while
+ // A search or filter change keeps the previous rows on screen while
// the new ones load (placeholderData). That is the moment to show progress:
// a bar along the top and the stale rows dimmed. Background refetches from
// realtime events do not qualify, so nothing flickers while reading. The
diff --git a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx
index a98e7f3a7..9bfa74ef7 100644
--- a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx
+++ b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx
@@ -62,6 +62,7 @@ export default function ComposeHistoryPanel({
// History is reference material: include snoozed threads too.
snoozed: "any",
},
+ `history:${address}:${tab}`,
!!address,
);
diff --git a/web/src/lib/api/hooks/app/unibox/useUniboxSearch.test.tsx b/web/src/lib/api/hooks/app/unibox/useUniboxSearch.test.tsx
new file mode 100644
index 000000000..ed871b7de
--- /dev/null
+++ b/web/src/lib/api/hooks/app/unibox/useUniboxSearch.test.tsx
@@ -0,0 +1,50 @@
+import type { ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { UniboxSearchParams } from "@/lib/api/models/app/unibox/UniboxSearch";
+import useUniboxSearch from "./useUniboxSearch";
+
+vi.mock("@/lib/api/client/app/unibox/searchIncoming", () => ({
+ default: () => new Promise(() => {}),
+}));
+
+describe("unibox scope transitions", () => {
+ it.each<{
+ name: string; initial: UniboxSearchParams; next: UniboxSearchParams;
+ scope: string; nextScope: string; keep?: boolean;
+ }>([
+ { name: "Sent to Inbox", scope: "folder:sent", nextScope: "folder:inbox", initial: { folder: "sent" }, next: { folder: "inbox" } },
+ { name: "All mail to Inbox", scope: "all", nextScope: "folder:inbox", initial: {}, next: { folder: "inbox" } },
+ { name: "All mail to Unread", scope: "all", nextScope: "unread", initial: {}, next: { unseen: true } },
+ { name: "mailbox change", scope: "mailbox:a", nextScope: "mailbox:b", initial: { accountIds: ["a"] }, next: { accountIds: ["b"] } },
+ { name: "history recipient change", scope: "history:a:all", nextScope: "history:b:all", initial: { address: "a" }, next: { address: "b" } },
+ { name: "search within Inbox", scope: "folder:inbox", nextScope: "folder:inbox", initial: { folder: "inbox" }, next: { folder: "inbox", query: "hello" }, keep: true },
+ { name: "filter within All mail", scope: "all", nextScope: "all", initial: {}, next: { unseen: true }, keep: true },
+ ])("keeps previous rows only within the same scope: $name", ({ initial, next, scope, nextScope, keep = false }) => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const row = {
+ id: "sent-message", email_id: "mailbox", thread_id: "outbound-thread",
+ from_addr: ["me@example.com"], to_addr: ["client@example.com"],
+ subject: "Sent message", snippet: "Hello", internal_date: "2026-09-16T00:00:00Z",
+ seen: true, message_count: 1, has_unread: false, labels: [],
+ };
+ client.setQueryData(["unibox", "search", initial, scope], {
+ pages: [{ data: [row], pagination: { has_more: false, next_cursor: null } }],
+ pageParams: [null],
+ });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+