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,
MessageListreads pages 0…page of each andmergeSourcesperforms 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) * pageSizemessages 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 anErrorStatewith retry, or anEmptyStatewith Reconnect forreconnect_required, above the others. - Cards.
groupCardsjoins same-thread messages of one account within the page into one hostCardGrid(layout="list") card; a thread split across pages shows a card on each. The open card isselected, ticked onesbulkSelectedthroughCardSelectCheckbox. Each card is wrapped in the hostContextMenuTriggerand 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 aMailboxRowinside aContextMenuTrigger(MailboxRowspreads the trigger's handlers onto itsli) with the same entries in itssmkebabActionMenu. 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 postPOST …/folders/:folderId/bulkonce per mailbox (markRead, oremptywithconfirmed:trueafter aConfirmDialogcounting the summedtotal), thenonSyncevery targeted mailbox and report the first refusal: a mailbox that accepted still changed. A Microsoft category offers Colour (MAIL_LABEL_COLORS, the current onechecked,PATCH …/labels/:id {color}) instead of Rename; IMAP keywords and Gmail labels offer Rename; both offer Delete. Create and rename areMailboxDialog, a smallModalwith oneField; delete is aConfirmDialog(a folder delete sendsconfirmed:true). Role folders are not renamed or deleted. Failures stay in the dialog with the typed text and keep the name; HTTP 409unsupported_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 aContextMenuTriggerthat opensaccountMenu(accountOptions.tsx: Sync now, Agent access with the current levelchecked); across all accounts it opens nothing. - List head.
PageToolbarwithRegisterSearch(sent after a 350ms pause) and the unread, starred and with-attachment switches under Options (filtersNamed="options"), and the hostPagerbelow the cards. - Bulk actions. The host
SelectionActionBaroffers read, unread, star, labels, move, archive and delete (permanent delete in Trash, behind aConfirmDialog).organize.tspostsmessages/bulkrequests 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-onlyIconButtons. Delete and Delete permanently take the shareddangervariant in both forms (IconButtonon a phone,Buttonon 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;labelChangesends only additions and removals, and a rejected save keeps the picker open. Move is oneMoveDialog(the folder tree asMailboxRows, without the current folder), shared by the bar and the conversation.UnsubscribeDialogconfirms 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.
Menus
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, whoseonOpen(point)stores{ ...point, items }asContextMenuState; the hostContextMenudraws it withlabelas the panel name andonCloseclearing 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 owncontextmenunever opens a second menu), and leaves text fields, selected text and dialogs portaled from the target to the browser. The page adds noonContextMenu, key or timer handler of its own. - Kebab.
kebabItems(entries)maps the same list to the hostActionMenugrammar:dangerbecomestone: 'danger',checked, submenus and dividers pass through, and every action runs fromonAfterClose.
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.onNavigateandConversation.onNavigatereceive the workspace's existing unsaved-writing guard. Agent reply and summary actions wait for it before calling the host'sstartConversation.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)readsGET …/messages/:idfor 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)andAccountsDrawer.onSync(accountId)read one mailbox again: folders, labels and the list, and the open conversation of that account throughConversation.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.onUpdateAccountandAccountsDrawer.onUpdateAccountare the oneUpdateAccount.
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()} />
requestis read once. Give every deliberate request its ownkey, 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
accountIdor 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 hostWorkspaceTakeover, 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.