import { expect, test, type Page, type Route } from "@playwright/test"; function deferred() { let release!: () => void; const promise = new Promise(resolve => { release = resolve; }); return { promise, release }; } function profile(id: string, protocol = "imap") { return { id, name: `${id} mailbox`, is_active: true, scope_type: "tenant", ...(protocol === "imap" ? { imap: { enabled: true, host: "imap.example.test", port: 993, security: "ssl", folder_mappings: { inbox: "INBOX" } } } : { servers: [{ id: `${id}-jmap`, protocol: "jmap", is_active: true, is_default: true, config: { session_url: "https://jmap.example.test/session" } }] }) }; } async function mockMailbox(page: Page) { const state = { profiles: [profile("Alpha"), profile("Beta")], reads: [] as URL[], writes: [] as string[], errors: [] as string[], failProfiles: false, failBootstrap: false, failDetail: false, intercept: null as null | ((url: URL, route: Route) => Promise), releasedReads: 0 }; page.on("pageerror", error => state.errors.push(error.message)); await page.route(url => url.pathname.startsWith("/api/"), async route => { const request = route.request(); const url = new URL(request.url()); if (request.method() !== "GET") { state.writes.push(url.pathname); return route.fulfill({ status: 405, json: { detail: "Fixture forbids mailbox writes" } }); } state.reads.push(url); if (state.intercept && await state.intercept(url, route)) return; let body: unknown = {}; if (url.pathname === "/api/v1/mail/profiles") { if (state.failProfiles) return route.fulfill({ status: 503, json: { detail: "Mailbox profile refresh unavailable" } }); body = { profiles: state.profiles }; } else if (url.pathname.endsWith("/mailbox/bootstrap")) { if (state.failBootstrap) return route.fulfill({ status: 503, json: { detail: "Mailbox refresh unavailable" } }); body = bootstrap(url); } else if (url.pathname.endsWith("/mailbox/folders")) body = folderCatalogue(); else if (url.pathname.endsWith("/mailbox/messages")) body = messageIndex(url); else if (/\/mailbox\/messages\/[^/]+$/.test(url.pathname)) { if (state.failDetail) return route.fulfill({ status: 503, json: { detail: "Message preview refresh unavailable" } }); body = messageDetail(url); } else if (url.pathname.endsWith("/address-write-targets")) body = { available: false, targets: [] }; return route.fulfill({ json: body }); }); return state; } function folderCatalogue() { return { ok: true, folders: [{ name: "INBOX", flags: [] }, { name: "Archive/2026", flags: [] }] }; } function messageIndex(url: URL) { const id = url.pathname.split("/")[5]; const folder = url.searchParams.get("folder") || "INBOX"; const offset = Number(url.searchParams.get("offset") ?? 0); const limit = Number(url.searchParams.get("limit") ?? 10); return { profile_id: id, folder, total_count: 24, offset, limit, next_cursor: "next-fixture-cursor", from_cache: false, messages: Array.from({ length: Math.max(0, Math.min(limit, 24 - offset)) }, (_, index) => ({ uid: String(offset + index + 1), folder, subject: `${id} message ${offset + index + 1}`, from_header: "Fixture sender ", to_header: "Reader ", flags: [], size_bytes: 1024, date: "2026-09-01T12:00:00Z" })) }; } function bootstrap(url: URL) { const messages = messageIndex(url); return { profile_id: messages.profile_id, folder: messages.folder, folders: folderCatalogue(), messages }; } function messageDetail(url: URL) { const parts = url.pathname.split("/"); const uid = parts[parts.length - 1]; const summary = messageIndex(new URL(url.href.replace(/offset=[^&]*/, "offset=0"))).messages[0]; return { message: { ...summary, uid, subject: `${url.pathname.split("/")[5]} message ${uid}`, body_text: `Fixture body ${url.pathname.split("/")[5]} ${uid}`, headers: {}, attachments: [] } }; } const workspaceBar = (page: Page) => page.locator('[data-workspace-action-scope="workspace"]'); const reload = (page: Page) => workspaceBar(page).locator('[data-page-action-slot="reload"] button'); const mailboxRows = (page: Page) => page.locator(".mailbox-message-row"); const mainMailboxReads = (reads: URL[]) => reads.filter(url => /\/mail\/profiles(?:$|\/[^/]+\/mailbox\/)/.test(url.pathname)); test("Mail has one persistent right-aligned Reload, including empty profiles, and recovers when a profile becomes available", async ({ page }) => { const state = await mockMailbox(page); state.profiles = []; await page.setViewportSize({ width: 1280, height: 900 }); await page.goto("/?mail-toolbar&language=en&theme=light"); await expect(reload(page)).toBeEnabled(); await expect(workspaceBar(page)).toHaveCount(1); await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Mailbox tools", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /Refresh (available profiles|folders only|messages only)/ })).toHaveCount(0); const [barBox, reloadBox] = await Promise.all([workspaceBar(page).boundingBox(), reload(page).boundingBox()]); expect(reloadBox!.x).toBeGreaterThan(barBox!.x + barBox!.width / 2); expect(barBox!.x + barBox!.width - reloadBox!.x - reloadBox!.width).toBeLessThanOrEqual(20); expect(barBox!.y).toBeLessThan(20); state.profiles = [profile("Alpha")]; await reload(page).click(); await expect(mailboxRows(page)).toHaveCount(10); await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Alpha"); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail Reload coherently refreshes catalogue, current IMAP page and selected preview without resetting expansion", async ({ page }) => { const state = await mockMailbox(page); await page.goto("/?mail-toolbar&language=en&theme=light"); await expect(mailboxRows(page)).toHaveCount(10); expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90); await page.getByRole("button", { name: "Next page", exact: true }).click(); await expect(mailboxRows(page).first()).toContainText("Alpha message 11"); await mailboxRows(page).first().click(); await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible(); const group = page.locator(".explorer-tree-node-wrap").filter({ has: page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ }) }); const toggle = group.locator(":scope > .explorer-tree-toggle"); await toggle.click(); const before = state.reads.length; await reload(page).click(); await expect(reload(page)).toBeEnabled(); await expect(mailboxRows(page).first()).toContainText("Alpha message 11"); await expect(mailboxRows(page).first()).toHaveClass(/is-selected/); await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible(); await expect(toggle).toHaveAttribute("aria-expanded", "true"); const reads = mainMailboxReads(state.reads.slice(before)); expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages/11"]); expect(reads[1].searchParams.get("offset")).toBe("10"); expect(reads[1].searchParams.get("refresh")).toBe("true"); expect(reads[1].searchParams.get("folder")).toBe("INBOX"); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail refresh failures preserve loaded index and preview, disclose failure, and leave recovery available", async ({ page }) => { const state = await mockMailbox(page); await page.goto("/?mail-toolbar&language=en&theme=light"); await expect(mailboxRows(page)).toHaveCount(10); await mailboxRows(page).first().click(); await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible(); for (const failure of ["failProfiles", "failBootstrap", "failDetail"] as const) { state[failure] = true; await reload(page).click(); await expect(reload(page)).toBeEnabled(); await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed"); await expect(mailboxRows(page)).toHaveCount(10); await expect(mailboxRows(page).first()).toHaveClass(/is-selected/); await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible(); state[failure] = false; await reload(page).click(); await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "current"); await expect(reload(page)).toBeEnabled(); } expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail grouping Reload refreshes only profiles and folders, preserving synthetic selection and collapsed state", async ({ page }) => { const state = await mockMailbox(page); await page.goto("/?mail-toolbar&language=en&theme=light"); const group = page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ }); await expect(group).toBeEnabled(); await group.click(); const before = state.reads.length; await reload(page).click(); await expect(reload(page)).toBeEnabled(); await expect(group).toHaveAttribute("aria-current", "true"); await expect(page.locator(".explorer-tree-node-wrap").filter({ has: group }).locator(":scope > .explorer-tree-toggle")).toHaveAttribute("aria-expanded", "false"); await expect(mailboxRows(page)).toHaveCount(0); expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/folders"]); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail Escape while Reload is pending does not reopen the dismissed preview", async ({ page }) => { const state = await mockMailbox(page); await page.goto("/?mail-toolbar&language=en&theme=light"); await expect(mailboxRows(page)).toHaveCount(10); await mailboxRows(page).first().click(); await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible(); const held = deferred(); let started = false; state.intercept = async (url, route) => { if (url.pathname.endsWith("/mailbox/bootstrap") && url.searchParams.get("refresh")) { started = true; await held.promise; await route.fulfill({ json: bootstrap(url) }); return true; } return false; }; const before = state.reads.length; await reload(page).click(); await expect.poll(() => started).toBe(true); await page.keyboard.press("Escape"); held.release(); await expect(reload(page)).toBeEnabled(); await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toHaveCount(0); await expect(page.locator(".mailbox-message-row.is-selected")).toHaveCount(0); expect(state.reads.slice(before).filter(url => /\/mailbox\/messages\/[^/]+$/.test(url.pathname))).toHaveLength(0); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail failed pagination keeps retained rows labelled with their committed page and size", async ({ page }) => { const state = await mockMailbox(page); await page.goto("/?mail-toolbar&language=en&theme=light"); await expect(mailboxRows(page)).toHaveCount(10); state.intercept = async (url, route) => { if (url.pathname.endsWith("/mailbox/messages")) { await route.fulfill({ status: 503, json: { detail: "Mailbox page unavailable" } }); return true; } return false; }; const before = state.reads.length; await page.getByRole("button", { name: "Next page", exact: true }).click(); await expect(reload(page)).toBeEnabled(); await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed"); await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3"); await expect(mailboxRows(page).first()).toContainText("Alpha message 1"); expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(1); await page.getByRole("combobox", { name: "Rows per page" }).selectOption("25"); await expect(reload(page)).toBeEnabled(); await expect(page.getByRole("combobox", { name: "Rows per page" })).toHaveValue("10"); await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3"); await expect(mailboxRows(page)).toHaveCount(10); expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(2); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail Reload reauthorizes profile availability and never keeps a removed account selected", async ({ page }) => { const state = await mockMailbox(page); await page.goto("/?mail-toolbar&language=en&theme=light"); await expect(mailboxRows(page).first()).toContainText("Alpha message 1"); state.profiles = [profile("Beta")]; await reload(page).click(); await expect(mailboxRows(page).first()).toContainText("Beta message 1"); await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Beta"); await expect(page.getByRole("option", { name: "Alpha mailbox" })).toHaveCount(0); state.profiles = []; await reload(page).click(); await expect(reload(page)).toBeEnabled(); await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled(); await expect(mailboxRows(page)).toHaveCount(0); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail JMAP Reload restarts its cursor chain but retains the server-side search", async ({ page }) => { const state = await mockMailbox(page); state.profiles = [profile("Alpha", "jmap")]; await page.goto("/?mail-toolbar&language=en&theme=light"); await expect(mailboxRows(page)).toHaveCount(10); await page.getByPlaceholder("Search messages").fill("message"); await expect.poll(() => state.reads.some(url => url.searchParams.get("q") === "message")).toBe(true); await expect(reload(page)).toBeEnabled(); await page.getByRole("button", { name: "Next page", exact: true }).click(); await expect(mailboxRows(page).first()).toContainText("Alpha message 11"); const before = state.reads.length; await reload(page).click(); await expect(reload(page)).toBeEnabled(); await expect(page.getByPlaceholder("Search messages")).toHaveValue("message"); await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3"); await expect(mailboxRows(page).first()).toContainText("Alpha message 1"); const reads = mainMailboxReads(state.reads.slice(before)); expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages"]); expect(reads[2].searchParams.get("protocol")).toBe("jmap"); expect(reads[2].searchParams.get("q")).toBe("message"); expect(reads[2].searchParams.has("cursor")).toBe(false); expect(reads[2].searchParams.get("offset")).toBe("0"); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail profile switches discard late bootstrap and preview responses instead of showing a previous account", async ({ page }) => { const state = await mockMailbox(page); const heldBootstrap = deferred(); let started = false; state.intercept = async (url, route) => { if (url.pathname === "/api/v1/mail/profiles/Alpha/mailbox/bootstrap") { started = true; await heldBootstrap.promise; await route.fulfill({ json: bootstrap(url) }); state.releasedReads += 1; return true; } return false; }; await page.goto("/?mail-toolbar&language=en&theme=light"); await expect.poll(() => started).toBe(true); await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Beta"); await expect(mailboxRows(page).first()).toContainText("Beta message 1"); heldBootstrap.release(); await expect.poll(() => state.releasedReads).toBeGreaterThan(0); await expect(mailboxRows(page).first()).toContainText("Beta message 1"); const heldPreview = deferred(); let previewStarted = false; state.intercept = async (url, route) => { if (url.pathname === "/api/v1/mail/profiles/Beta/mailbox/messages/1") { previewStarted = true; await heldPreview.promise; await route.fulfill({ json: messageDetail(url) }); state.releasedReads += 1; return true; } return false; }; await mailboxRows(page).first().click(); await expect.poll(() => previewStarted).toBe(true); await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Alpha"); await expect(mailboxRows(page).first()).toContainText("Alpha message 1"); await mailboxRows(page).first().click(); await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible(); const released = state.releasedReads; heldPreview.release(); await expect.poll(() => state.releasedReads).toBeGreaterThan(released); await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible(); await expect(page.getByText("Fixture body Beta 1", { exact: true })).toHaveCount(0); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail tools keeps advanced reads scoped, bounce permission explained, and Escape leaves selection intact", async ({ page }) => { const state = await mockMailbox(page); await page.goto("/?mail-toolbar&language=en&theme=light"); await expect(mailboxRows(page)).toHaveCount(10); await mailboxRows(page).first().click(); await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible(); const tools = page.getByRole("button", { name: "Mailbox tools", exact: true }); await tools.click(); const dialog = page.getByRole("dialog", { name: "Mailbox tools" }); await expect(dialog).toBeVisible(); await expect(dialog.getByRole("button", { name: "Bounce status", exact: true })).toBeDisabled(); for (let index = 0; index < 10; index += 1) { await page.keyboard.press("Tab"); expect(await dialog.evaluate(element => element.contains(document.activeElement))).toBe(true); } await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); await expect(tools).toBeFocused(); await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible(); for (const [action, expectedPath] of [ ["Refresh available profiles", "/api/v1/mail/profiles"], ["Refresh folders only", "/api/v1/mail/profiles/Alpha/mailbox/folders"], ["Refresh messages only", "/api/v1/mail/profiles/Alpha/mailbox/messages"] ]) { await tools.click(); const before = state.reads.length; await dialog.getByRole("button", { name: action, exact: true }).click(); await expect(dialog).not.toBeVisible(); await expect(reload(page)).toBeEnabled(); expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual([expectedPath]); await expect(mailboxRows(page).first()).toHaveClass(/is-selected/); } expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail authority switch invalidates a previous tenant's pending profile catalogue", async ({ page }) => { const state = await mockMailbox(page); const held = deferred(); let started = false; state.intercept = async (url, route) => { if (url.pathname === "/api/v1/mail/profiles" && !started) { started = true; await held.promise; await route.fulfill({ json: { profiles: [profile("PreviousTenant")] } }); state.releasedReads += 1; return true; } return false; }; state.profiles = [profile("CurrentTenant")]; await page.goto("/?mail-toolbar&language=en&theme=light&switch-tenant"); await expect.poll(() => started).toBe(true); await page.getByRole("button", { name: "Switch fixture tenant" }).click(); await expect(mailboxRows(page).first()).toContainText("CurrentTenant message 1"); held.release(); await expect.poll(() => state.releasedReads).toBeGreaterThan(0); await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("CurrentTenant"); await expect(page.getByRole("option", { name: "PreviousTenant mailbox" })).toHaveCount(0); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); }); test("Mail narrow German workspace keeps its sole Reload visible and the grouped tools dialog within the viewport", async ({ page }) => { const state = await mockMailbox(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/?mail-toolbar&language=de&theme=light&bounce-allowed"); await expect(reload(page)).toBeEnabled(); await expect(reload(page)).toHaveText(/Neu laden/); await expect(page.locator(".mailbox-message-head")).not.toBeVisible(); expect(await mailboxRows(page).first().evaluate(element => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1); expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90); await expect(workspaceBar(page).locator('[data-page-action-slot="reload"]')).toHaveCount(1); const bounds = await reload(page).boundingBox(); expect(bounds!.x).toBeGreaterThanOrEqual(0); expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true); await mailboxRows(page).first().click(); const preview = page.getByText("Fixture body Alpha 1", { exact: true }); await expect(preview).toBeVisible(); await preview.scrollIntoViewIfNeeded(); await expect(preview).toBeInViewport(); await expect(reload(page)).toBeVisible(); expect((await reload(page).boundingBox())!.y).toBe(bounds!.y); await page.getByRole("button", { name: "Postfachwerkzeuge", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Postfachwerkzeuge" }); await expect(dialog).toBeVisible(); await expect(dialog.getByRole("button", { name: "Zustellrückläufer", exact: true })).toBeEnabled(); expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true); await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible(); expect(state.errors).toEqual([]); expect(state.writes).toEqual([]); });