Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 27. října 2026

Procházet dokumentaci · Email browser workspace

Tento článek zatím není přeložen. Je dostupný v angličtině.

Email browser workspace

Runtime and routes

The committed web/index.js and web/index.css are built from web-src/index.tsx with the plugin UI kit. Both manifest and registration require UI API 73 (ContextMenuTrigger and the shared menu grammar); a host web below 73 does not mount the page. The manifest declares web.layout: "workbench" (the shell frames the page at the wide workbench measure) and web.wordmark: false. The Email navigation contribution uses the closed Mail icon. No browser source imports core implementation modules; runtime.ts narrows published components with AssertPublished and declares only components consumed by the workspace. Account settings use SettingsGroup, SettingsRow, SettingsValue and SettingsState; SettingsList and SettingsItem are not part of this local subset. The host's published UI contract is unchanged.

Pages are /p/email, /p/email/:accountId, /p/email/:accountId/:messageId, and /p/email/oauth/callback. Next passes URL-encoded dynamic segments through the host page matcher. EmailWorkspace decodes accountId and messageId once at page ingress, scopes the sidebar to that account and opens that message's thread. Message IDs remain opaque; runtime.ts encodes each raw ID once for API paths. Alert and saved-draft deep links therefore retain their canonical stable IDs. Browser requests go through the host api client using /plugins/email/api/ paths. Request and response types come from src/apiContract.ts; the API reference owns validation and endpoint semantics.

Workspace and request ownership

The page is a three-pane mail application. ModuleHeader names it in the shell bar and WorkspacePage holds one frame whose inline height is hooks.useFillHeight on the frame: the page fills the window below the shell bar and never scrolls as a whole; each pane scrolls inside itself. The frame draws no edge, corners or surface of its own; the pane separators and the rules inside the panes are the only lines. Above the phone breakpoint it starts 2rem (pt-8, inside the filled height) under the shell bar, the gap a workspace hero leaves; on a phone it sits directly under the bar. The panes are one host ResizablePanels group in every mode (storageKey email:panes; mailboxes 240px, minimum 180; messages 340px, minimum 280; conversation the rest, minimum 320), whose two separators are labelled, keyboard-steppable and remembered in this browser. Below the host phone breakpoint (hooks.useMobileViewport), and whenever the frame (measured with a ResizeObserver) is narrower than the three minimums together (780px: a tablet, or a laptop with the app sidebar open), activePaneId shows one pane at a time with a back control in each pane head, instead of three squeezed below their minimums. The other panes stay mounted and inert, so crossing that width either way never remounts a pane: a message being written keeps its text, recipients and uploads, and while one is mounted it is the pane shown. Nothing renders before the viewport and the frame are measured. The page owns no control geometry: every row of adjacent controls is one shared height step (36px, 44px on a coarse pointer).

useMailboxes loads each account's folders and labels with its own AbortController; a reload keeps the rows already shown. mailboxModel.ts holds the pure rules:

  • Sources. One account lists a folder or a label (never both); a role row is that role's folder. All accounts lists role folders only: each mailbox's own folder of that role, read together. A folder or label ID belongs to one mailbox, so choosing another account (or all of them) takes a folder or label view back to the Inbox; a role view stays. Paging is offset-based and counts messages, not threads. With several sources, MessageList reads pages 0…page of each and mergeSources performs a k-way merge: it chooses the newest current head by date, breaking equal dates by source order, without reordering messages within a source. IMAP folder pages retain UID order, so the combined list is not necessarily globally date-sorted. The first (page + 1) * pageSize messages of each source suffice for that merged prefix; loading later source pages cannot move already merged messages. This keeps page boundaries stable for unchanged source data without additional reads or metadata. Live arrivals and removals can still shift offset pages. The total is the sum. A mailbox whose folders or list failed is an ErrorState with retry, or an EmptyState with Reconnect for reconnect_required, above the others.
  • Cards. groupCards joins same-thread messages of one account within the page into one host CardGrid (layout="list") card; a thread split across pages shows a card on each. The open card is selected, ticked ones bulkSelected through CardSelectCheckbox. Each card is wrapped in the host ContextMenuTrigger and opens the message menu (below); the keyboard reaches it through the subject button, the card's focusable control.
  • Sidebar. The account SelectMenu, accent Compose and Manage accounts, then the folder tree (folderTree: roles in a fixed order, then names by the locale collator, children under their parent) and the labels, each a MailboxRow inside a ContextMenuTrigger (MailboxRow spreads the trigger's handlers onto its li) with the same entries in its sm kebab ActionMenu. A folder offers New subfolder, Rename and Delete (own folders only), then Mark all as read, and Empty Trash or Empty Junk on those two roles; across all accounts a role row offers the last two for every loaded mailbox's folder of that role, its unread count summed. Folder-wide actions post POST …/folders/:folderId/bulk once per mailbox (markRead, or empty with confirmed:true after a ConfirmDialog counting the summed total), then onSync every targeted mailbox and report the first refusal: a mailbox that accepted still changed. A Microsoft category offers Colour (MAIL_LABEL_COLORS, the current one checked, PATCH …/labels/:id {color}) instead of Rename; IMAP keywords and Gmail labels offer Rename; both offer Delete. Create and rename are MailboxDialog, a small Modal with one Field; delete is a ConfirmDialog (a folder delete sends confirmed:true). Role folders are not renamed or deleted. Failures stay in the dialog with the typed text and keep the name; HTTP 409 unsupported_operation (an IMAP server without keywords) is stated and never retried. Labels the server reports as unsupported show that sentence and no create action. While one account is scoped, the account picker is wrapped in a ContextMenuTrigger that opens accountMenu (accountOptions.tsx: Sync now, Agent access with the current level checked); across all accounts it opens nothing.
  • List head. PageToolbar with RegisterSearch (sent after a 350ms pause) and the unread, starred and with-attachment switches under Options (filtersNamed="options"), and the host Pager below the cards.
  • Bulk actions. The host SelectionActionBar offers read, unread, star, labels, move, archive and delete (permanent delete in Trash, behind a ConfirmDialog). organize.ts posts messages/bulk requests per account, in order, with at most 100 IDs per request; labels and move appear only while the selection is one account's. A refusal stops later requests and reports the typed reason, but refreshes every target mailbox and the list: earlier requests may have succeeded. Still-listed cards keep their selection, rebound to the fresh card objects (flags, labels and message IDs), and vanished cards leave it. On a phone the actions are icon-only IconButtons. Delete and Delete permanently take the shared danger variant in both forms (IconButton on a phone, Button on a desktop), as the conversation's delete does, and nowhere a local class.
  • Pickers. Labels use the host ManageSelectionModal, ticked where every selected message carries the label; labelChange sends only additions and removals, and a rejected save keeps the picker open. Move is one MoveDialog (the folder tree as MailboxRows, without the current folder), shared by the bar and the conversation. UnsubscribeDialog confirms a mailto-only unsubscribe for the message that lists it.

The conversation pane reads GET …/messages/:id/thread, which changes nothing; opening a card then marks its unread messages read with explicit setFlags batches of at most 100 IDs. Completion is recorded only once all batches succeed. A refused mark-read reports its error, refreshes the mailbox and list through onChanged, and reads the thread once to reconcile batches that already committed. That read does not mark messages or replace the composer; a failed reconciliation uses the existing thread error state. No mutation retries automatically: a later explicit Sync now can attempt only the still-unread IDs again. The thread runs oldest first with the newest message open; older ones are one-line summaries that expand. The head has the subject, its labels and one row of icon actions (archive, delete, then labels and move except on a phone) and a kebab with the whole message menu. Reply, Reply all, Forward and Reply with agent follow the thread; a reply mounts the composer there, in their place. Actions apply to the card's messages, the ones the list showed.

The message menu is one builder, messageMenu(context) in organize.ts, for a card, a selection and the open conversation. Its groups are replies (Reply, Reply all, Forward); read state and star with the Labels and Move to folder submenus; Archive (not in Trash), Mark as spam or Not spam, Unsubscribe, Delete or Delete permanently; and the agent drafts (Reply with agent, Summarize with agent). A group with no row leaves no divider. Replies, Unsubscribe and the agent drafts belong to one message only. Labels (one checked row per label every target carries, toggling setLabels, then New label) and Move to folder (the folder tree without the listed folder, then New folder) appear only while the targets are one mailbox's and its labels or folders loaded; New label and New folder open MailboxDialog and apply its result, and a refused filing still reads the mailbox again so the new label or folder shows. Spam moves into each mailbox's own junk folder, Not spam (listed in Junk) into its inbox, through a per-mailbox OrganizeRequest; the row is left out when a target mailbox has no such folder. A right-click on a card inside a selection of two or more acts on the whole selection and clears it after success; any other card acts on itself and leaves the selection alone. A card's reply calls onReply (which reads the full message first) and its agent drafts reference the opaque ID only. Unsubscribe uses the newest message that lists List-Unsubscribe (MailHeader.listUnsubscribe) and posts for that message's ID.

Attachments are chips (ThreadMessage.tsx): glyph, the filename as a download link, the size and an sm kebab. One entry list, in the kebab and through the chip's ContextMenuTrigger, offers Preview (only when previewKind allows), Download (clicks the chip's own link) and Save to project. Preview of a PNG, JPEG, GIF or WebP up to PREVIEW_MAX_BYTES (20 MiB: the daemon reads the whole part from the mail server and the browser buffers the whole blob) open in the host ImageLightbox: the bytes are fetched with an AbortController and wrapped in a blob of the allow-listed declared type, never the response's; the object URL is revoked when the preview closes, changes or the conversation unmounts. A PDF opens in a centred Modal (size="lg", ModalBody fill) around the host PdfPreview; on a phone Preview opens the same download route in a new tab (noopener,noreferrer). SVG, HTML and every other type only download. Save to project (SaveToProjectDialog.tsx) picks a Project from hooks.useProjects() named by utils.projectName (the first one preselected) and a folder defaulting to attachments, reads the attachment from its download route with the session cookie, wraps the bytes in a File with the attachment's own filename and declared type, and hands it to utils.uploadProjectFile(file, { projectId, directory }, { signal }), which resolves name collisions itself. A { failed } answer keeps the dialog with its reason (or the generic text); success toasts the stored relative path and closes; closing aborts the read or upload. Loading, error with retry and the empty "no project" state come from the host states. Unsubscribe opens the sender's https address in a new tab without an opener; a mailto-only sender gets a ConfirmDialog, then POST …/unsubscribe with confirmed:true for the message that lists it.

The composer belongs to Composer.tsx (below). The workspace mounts one per deliberate request with its own key and unmounts it on every callback, onSaved included; a new message closed with no conversation open leaves a single pane on the list. While one is mounted, opening another message, starting a new one, going back on a phone or opening Manage accounts asks first in a ConfirmDialog: the composer keeps the sending account it chose, so it is closed knowingly before that account can be removed. Switching folders or accounts does not touch it, and an archive or move that removes the open conversation leaves the conversation open while it is mounted.

Account management is a sectioned WorkspaceDetailRail, the presentation of the host's Settings drawers (provider and user drawers): one SettingsGroup per account titled by its address beside the provider glyph, with the account name and provider as its description. SettingsRow puts each label left and its SelectMenu or Toggle right; the host stacks them in a phone-narrow drawer. The group's kebab ActionMenu and a ContextMenuTrigger around the group (a plain wrapper block, since SettingsGroup takes no extra props) offer accountMenu (Sync now, Agent access) and Remove account, still protected by ConfirmDialog; a mailbox that needs signing in again gets a Connection row with a ghost Reconnect action. Add account follows the groups as a ghost button, as in the provider drawer. The new-account and reconnect windows use the same sectioned rail with their actions and status in ModalFooter. The runtime narrows the published components through AssertPublished; there is no plugin-owned card, grid or new core primitive.

PATCH sends the complete access/alerts pair through the workspace's one UpdateAccount (AccountsDrawer.tsx), which keeps the saved account and rejects with the request's error for the caller to show where the reader acted. The row is updated only after the server succeeds; a failed removal leaves the account visible. Remote mailbox contents are never deleted by this UI. Successful or unchecked reads add no connection-status prose; only reconnect and read failures appear. Nested ElowenApiError.details.error.code is classified through the canonical code union. HTTP 422 connection-test codes use the validated details in apiContract.ts to show the failed protocol/host and exposed SAN/CN names in the existing status area. Certificate names missing from the library get explicit copy; authentication and network failures never suggest a certificate workaround. Google callback errors use the same formatter in their existing error state. HTTP 409 reconnect_required displays a reconnect action and retains the account ID. ReconnectModal sends password-based updates to POST accounts/:accountId/reconnect: only password, plus explicit manual servers when expanded. Otherwise the server reuses its stored settings. ServerSettings is the same typed pair of Incoming/Outgoing Settings groups used by the new-account and reconnect flows; the shared username row belongs to the caller's server group beside the manual switch. It never infers stored credentials or server settings from the public account projection.

Google reconnect starts with exactly {accountId}; the existing callback submits only state plus code/denial. The target remains bound to the server's PKCE state, not browser callback input. Microsoft starts the owning control's sign-in and stores only the target ID in per-tab sessionStorage['email:microsoft-reconnect']. On return, the workspace resolves that ID against the current owner's account list and opens the returned-mailbox choice instead of the new-account import flow. The selected {ref} is posted to the existing account's reconnect endpoint. Explicit cancellation or acknowledged success clears that navigation intent. Provider/address/owner and access/alerts never appear as reconnect overrides.

The dialog closes and live mailbox reads refresh only after an acknowledged successful response. Rejected credentials or a different mailbox leave the row, ID, preferences and open mailbox unchanged; the API/foundation own verification and credential rollback. No reconnect path calls DELETE, accounts/connect or microsoft/import.

The page has one menu grammar, the host ContextMenu's own (UI API 71), typed in runtime.ts beside the host components it narrows: MenuEntry is an action { label, icon?, onClick, danger?, disabled?, checked? }, a submenu { label, icon?, disabled?, items } or DIVIDER; checked (true or false) draws a menuitemcheckbox toggle. A target builds one MenuEntry[] beside its data and hands it to both host menus:

  • Right-click, keys and touch. The target's DOM element is wrapped in the host ContextMenuTrigger, whose onOpen(point) stores { ...point, items } as ContextMenuState; the host ContextMenu draws it with label as the panel name and onClose clearing the state. The trigger is the only opener: it handles right-click under the pointer, the Menu key and Shift+F10 under the focused element, and a 500 ms touch or pen long press (iOS included; Android's own contextmenu never opens a second menu), and leaves text fields, selected text and dialogs portaled from the target to the browser. The page adds no onContextMenu, key or timer handler of its own.
  • Kebab. kebabItems(entries) maps the same list to the host ActionMenu grammar: danger becomes tone: 'danger', checked, submenus and dividers pass through, and every action runs from onAfterClose.

Either way an action runs after its menu closed with focus back on the opener, so it may open a dialog directly. menus.ts holds only DIVIDER and kebabItems; builders and their state live with the component that owns the data (messageMenu in organize.ts, the folder and label menus in Sidebar.tsx, accountMenu in accountOptions.tsx, the chip's in ThreadMessage.tsx). ReplyKind belongs to composerModel.ts with the compose requests, so the list, the conversation and the menu builder share it without importing one another.

MailboxDialog.tsx is the one create, rename and delete dialog for folders and labels. Its onDone receives MailboxDialogResult: { folder } or { label } as the route answered a create or rename (so a caller can file a message into a folder it just created), or { removed: { folder } | { label } } after a delete. Sidebar compares a rename's returned ID with its previous ID and reports the old subtree as invalidated through onChanged(accountId, {folders?, labels?}), just as a delete does. folderSubtree follows MailFolder.parentId through the loaded tree, never path-prefix guesses; renamed IMAP parents invalidate open children and stop list requests to their old paths. The workspace returns the selected invalidated source to Inbox at page zero and closes an opened conversation from that folder, whose IMAP message IDs are no longer usable. MailLabelList.folderBacked identifies Gmail labels as folders, including app-password accounts whose stored kind is IMAP: both names invalidate together, and a label list records its folder in the opened conversation. Keywords and Microsoft categories do not bind a folder. A rename that retains its ID keeps the view, including descendants. ThreadMessage.tsx draws one message of the timeline with its attachment chips.

The workspace owns the callbacks a menu reaches beyond its own pane:

  • MessageList.onNavigate and Conversation.onNavigate receive the workspace's existing unsaved-writing guard. Agent reply and summary actions wait for it before calling the host's startConversation. Sidebar.onFolderChange(accountId, folderIds, action) uses the same guard for rename/delete dialogs (including folder-backed labels) when the affected subtree contains an inline reply's source. Unrelated folders and new-message composers are unaffected. Cancel leaves the same composer mounted; confirmation discards it and runs the deferred action.
  • MessageList.onReply(kind, card) reads GET …/messages/:id for the card's newest message, then opens the card with a composer under it, behind the same unsaved-writing guard as any navigation; it rejects when the read fails, for the list to report.
  • Sidebar.onSync(accountId) and AccountsDrawer.onSync(accountId) read one mailbox again: folders, labels and the list, and the open conversation of that account through Conversation.refresh, which re-reads the thread in place and keeps a reply being written, including during a read failure or reconnect-required state. The error replaces the thread content, not the composer's place in the React tree, and retry keeps that same editor. The plugin caches no mail, so this is the whole of Sync now; the daemon's alert poll is unaffected.
  • Sidebar.onUpdateAccount and AccountsDrawer.onUpdateAccount are the one UpdateAccount.

Untrusted content and reply

Only runtime SandboxedHtml renders message HTML, scriptless on light paper. The frame is as tall as its document, so the conversation pane is the one scrolling region around it. Remote images load by default except in Junk, where they wait for an explicit Show images on that message. The inert HTML template and URL/CSSOM parsing detect remote image sources; CID parts already arrive as data URLs from the adapter. Plain text is rendered as escaped, whitespace-preserved text. Attachment links point to the authenticated same-origin byte endpoint with download disposition, never inline HTML.

Reply calls runtime startConversation({draft}): localized instruction plus the JSON-quoted opaque message ID. It does not interpolate untrusted body text into instructions, call compose/send endpoints or submit a chat turn. The host owns conversation creation, default Project selection and composer prefill.

Composer

Composer.tsx is the person's own composer for a new message, a reply, a reply to all and a forward; composerModel.ts holds its pure rules. The workspace mounts it:

import { Composer, type ComposeRequest } from './Composer';
// ComposeRequest = { kind: 'new'; accountId?: string }
//   | { kind: 'reply' | 'replyAll' | 'forward'; accountId: string; message: MailMessage }
<Composer key={requestId} accounts={accounts} request={request}
  onClose={close} onSaved={(accountId, id) => close()} onSent={accountId => close()} />
  • request is read once. Give every deliberate request its own key, keep the composer mounted until it calls one of its callbacks, and unmount it on each: a second save would create a second draft, because the API has no draft update. Unmount it too when its account is removed; a missing account throws.
  • Mount it from a person's action on a rendered page. The quote introduction is written once at mount from usePluginStrings, which is empty until the plugin listing has loaded.
  • A new message starts on accountId or the first account and may switch From to any connected account, whatever its agent access. A reply or forward is locked to the account its source was read from, because the source ID is valid only there.
  • It fills the height its parent gives it (header, scrolling ModalBody, ModalFooter). Below the host phone breakpoint (hooks.useMobileViewport) it raises itself in the host WorkspaceTakeover, whose back control and Escape are its close. Nothing renders before the viewport is measured.

Recipients are host TokenList fields inside SettingsRows. The composer splits a committed line on commas and semicolons and drops case-insensitive duplicates; every address is tested with COMPOSE_ADDRESS_PATTERN, the pattern the compose route's z.email() uses. An invalid address stays as a chip in the danger tone with its localized reason, and the footer says to fix it; it blocks saving and sending. A draft may have no recipients; sending needs one. A reply answers Reply-To or the sender (the recipients when the source is this account's own sent mail); reply-all adds the original To and keeps Cc, both without this account's address. Display names the source gave an address are kept in the request body.

The body is the host RichTextEditor. A reply or forward starts with an empty line, a localized introduction (the forward header lists From, Date, Subject, To and Cc) and the original: its HTML is passed to the editor unchanged, and the editor parses it in an inert document into its closed schema, so no blockquote, style, image or script survives and the HTML and text that are saved or sent are the editor's one value. Plain-text mail is quoted as paragraphs built from text nodes. The editor's first report is the baseline for the discard check.

Attachments: a forward lists every source attachment with a switch, all on at first; only the selected parts are sent as {kind:'source',part}. Files from the computer are kept as Files and encoded to base64 only when saving or sending. Their combined size is checked against MAX_UPLOADED_ATTACHMENT_BYTES, and on a Microsoft account every uploaded or forwarded file against MAX_MICROSOFT_ATTACHMENT_BYTES; a breach blocks saving and sending, the attachments card names the file or the total, and the footer says why the actions are off, because a long quote can push that card out of view. A failed save or send keeps every field and attachment.

Send encodes the message once and opens the host ConfirmDialog, with the same header, warning icon, description paragraph and footer as delete confirmations. One short localized sentence names the sole recipient's address or counts all To/Cc/Bcc recipients; there is no sender, subject or attachment summary. The Send action stays accent. Confirming posts exactly that snapshot with confirmed:true. The dialog blocks dismissal and a second confirmation while the request runs, shows a failure in place and closes on success before onSent.

A delivery_incomplete answer (HTTP 502: the provider accepted the message but refused some recipients, or its Sent copy or draft cleanup failed) is not a failure to retry: the composer closes the confirmation, stays locked, shows the localized deliveryIncomplete warning through the host toast and calls onSent, so the caller closes it and reloads the mailbox.

Discard, the phone back control and Escape close at once when nothing was added or changed, and otherwise ask in a ConfirmDialog first. While a save runs every control is disabled; TokenList has no disabled state, so each recipient field sits in a disabled fieldset. On a desktop the caret starts in To for a new message or a forward and at the top of the body for a reply; a phone opens no keyboard by itself.

Connection and OAuth lifecycle

The add-account rail consumes provider availability rather than inspecting installed plugins; its Account type select offers only available providers. Microsoft is conditional, states the single-tenant restriction, starts the owning control's sign-in and imports a returned mailbox reference. Microsoft access/alerts settings are chosen after sign-in, so the redirect cannot discard an earlier selection. Newly signed-in Microsoft addresses absent from the local account listing reopen the import choice. Google offers OAuth only when configured; otherwise it uses the app-password path. Other email and app-password connection discover servers first. Find server settings shows the discovered IMAP and SMTP endpoints and which source answered (domain configuration, DNS SRV records or ISPDB); failed discovery opens manual IMAP/SMTP settings. TLS and mandatory STARTTLS are the only choices. Passwords remain component state and are cleared on successful creation or unmount.

Google callback reads state plus code OR denial, removes query parameters from history, and submits exactly once, including StrictMode effect replay. It never forwards error_description. The server owns the single-use mutation after submission; unmount does not replay or cancel an already-consumed exchange. Success replaces history with the server's fixed return destination. Failure offers navigation back for a new flow.

Verification

tests/emailWeb.test.tsx renders the workspace on the candidate's real ResizablePanels, CardSelectCheckbox, SelectionActionBar, PageToolbar, SandboxedHtml, ImageLightbox and PdfPreview beside the registry UI ports: the mailbox model (merge, cards, tree, label diff, preview types), including a 26-message UID-ordered source merged with one other account at page size 25, prefix stability and equal-date head ties. It also covers loading, empty and error states, the once-decoded deep link, a mailbox that needs reconnecting beside a working one, the side-effect-free thread read and its explicit mark-read, blocked Junk images, the agent reply by reference, a bulk action, the composer closing after a saved draft and after an accepted but incomplete send (its warning through the toaster the page is mounted in), a single pane back on the list after a new message closes, an account switch that starts in the Inbox, the question before managing accounts while writing, a new message and a reply kept while the frame switches between three panes and one, and the account, reconnect and callback flows. Its menu foundation cases cover kebabItems, the candidate's real ContextMenuTrigger (right-click, Shift+F10, one open per long press with Android's extra contextmenu) with the registry's menu doubles, the dialog results and the UI API 73 registration. tests/ui/emailFixtures.tsx is the shared email test host (sample mail, the stateless mailbox API fake, the mounted workspace and the rightClick, menuKey and longPress gestures) for every email UI suite. Its card and conversation cases cover the message menu: the single-card rows in order from right-click, Shift+F10 on the subject and a long press; a reply that reads the full message first; selection versus single-card targets; Spam into each mailbox's own Junk and Not spam back to the Inbox; checked labels, create-then-apply labels and folders; agent drafts by opaque ID; confirmed permanent delete in Trash; Unsubscribe only where listed and posted for the message that lists it; and the same rows in the conversation kebab. tests/emailNavigation.test.tsx covers guarded agent exits, composer identity and recipients across sync errors and retry, renamed source invalidation (including Gmail's folder-backed labels), partial cross-account bulk changes, refreshed selection flags and 101-message mark-read batching against the real bulk schema. tests/emailSourceLifetime.test.tsx covers Gmail app-password label rename/delete guards, parent-folder descendant guards and invalidation, and stable-ID renames that retain the reader. tests/e2e/email/sourceLifetime.mjs drives those source changes through the built bundle in the real host harness at 320px (mobile/touch) and 1440px in Czech, including keyboard menus, composer preservation across resizing and assertions that old folder queries stop. tests/emailSidebarMenus.test.tsx covers the folder, role, label and account menus: rows in the kebab equal the right-click rows, Mark all as read and confirmed Empty (with partial refusals across mailboxes), the open thread read again after a folder-wide change, Microsoft Colour against IMAP Rename, Sync now and Agent access from the drawer group and the scoped account picker. tests/emailAttachments.test.tsx covers the chip (link, size, kebab), its menu from every opener, Preview only for previewable types, the phone PDF tab, Download through the chip's link, and Save to project: the uploaded File, the chosen folder, refusal reasons, loading, retry and empty catalog states and abort on close. tests/emailComposer.test.tsx renders the composer on the candidate's real TokenList, RichTextEditor and ConfirmDialog: recipient splitting and validation, reply and forward starts, the attachment limits, the quoted body's closed HTML, the confirmed send snapshot sent once, inventory kept after a failed save, the discard confirmation and the phone takeover.

The browser checks run the built bundle in an owned candidate's real web/fake-daemon harness. The core-side mailbox specs drive a stateful mail API double (Gmail with labels, Microsoft with categories that refuse a rename, IMAP without labels) at 320, 390, 800, 1024 and 1440px in cs, sk and en: no page scroll, three panes or one by frame width, one control height per row, folders and labels managed, the thread, previews, unsubscribe, paging, search, the remembered dividers, the composer guard (Manage accounts included, then removing the sending account) and its close after a save, a send the provider accepted but did not complete, an account switch from a folder, a new message and a reply kept across 1440↔900px in both directions with the dividers restored, and a newsletter wider than a 320px phone that pans sideways under a touch drag. This repository's tests/e2e/email/*.mjs double only mailbox transport in the page: web.mjs covers the no-account state, SRV discovery through the compiled discoverMailServers, connection errors, the message, script inertness, the agent reply prefill without sending, Google and Microsoft sign-in, folder and Options filtering and account management at touch 320px and 1440px in cs/sk/en; reconnect.mjs covers IMAP, Google and Microsoft reconnect refusal and success with exact request bodies and unchanged IDs/preferences; deepLink.mjs opens real alert and saved-draft URLs with once-encoded IDs; accounts.mjs checks the account drawer in Czech. Expected CSP/sandbox diagnostics and deliberate HTTP failures are separated from unexpected console and page errors. Screenshots are evidence, not shipped fixtures.

Build only this plugin with node scripts/build-web.mjs email, selecting the built candidate through ELOWEN_CORE_ROOT. Browser checks do not prove real mailbox login, provider administration or the blocked IMAP/SMTP transport.