* Use the full width for message bodies on mobile
The reader indents the subject, sender row and body by the avatar column
(pl-12/pl-16) at every width, which leaves a 64px gutter on a phone. Below
the sm breakpoint the subject, body, attachments and the scheduled-send
banner's wrapper now start at the page edge; wider screens are unchanged.
* improve mobile layout for email content
---------
Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
Message attachments honoured the sender's inline disposition for any type, and
the Drive and preview checks compared raw Content-Type strings, so
image/svg+xml;charset=utf-8 or image/png, text/html passed. The per-route CSP
sandbox was the only guard, and on the Node runtime Next.js drops it in favour of
the global CSP from next.config, which allows inline script.
Parse the MIME essence, allowlist inline types, and send back only the parsed
type.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Secondary text was mostly text-neutral-400 or neutral-500 on the tinted sidebar background, which measured 2.4:1 and 4.45:1 against the 4.5:1 AA minimum. In the dark theme neutral-400 on the card surfaces measured 3.5 to 3.9:1.
Light theme: neutral-500 goes from #737373 to #6b6b6b, which clears 4.5:1 on the sidebar and selected-row backgrounds, and text that used neutral-400 (11 and 12 px captions, timestamps, helper lines, key hints, chart labels, empty-state lines) now uses neutral-500. Placeholders, decorative icons and disabled text are left alone. Dark theme: neutral-400 becomes #9aa0a6 and neutral-500 #a8aeb4. The active command palette item used text-blue-100, which the dark theme remaps to a dark blue (1.75:1 on the blue background); it is white now. The success badge uses green-800 instead of green-600 on its tint.
A few informative icons (close buttons, the recipient chip remove button, the shared-folder marker) go from neutral-400 to neutral-500 as well, for the 3:1 non-text minimum.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Give pages a title, one h1 and unique landmarks
Settings, admin, calendar, booking, drive and compose pages kept the title of whatever page was shown before, because only the mail folders updated document.title. A small RouteTitle component, mounted in each layout, now sets "<page> - <app name>" from a table of the existing navigation labels. The sign-in, forgot-password and reset-password pages get their own titles from generateMetadata.
Headings: the folder list on the desktop layout had no h1 (it was only rendered on narrow screens), and the admin pages hid their h1 with display: none on phones in favour of an h2 outside the main landmark. The list now always has a heading (an h2 when a message with its own h1 is open), the visible title next to the toolbar is decorative, admin pages keep their h1 on phones through sr-only, the mobile top bar title is decorative, and the calendar, account, inbox settings, import, export and rules pages get a screen-reader-only h1. The admin overview cards use h2 so the heading levels no longer skip.
Landmarks: the sidebar nav is named after the app and the settings navs after their sections, so they can be told apart. The admin page chrome (license badge and account menu) is a header instead of a bare span, so it is inside a landmark.
* Test the route title lookup
Covers the longest-prefix match and the routes that keep their own title.
---------
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Error and status messages were mounted as plain paragraphs, so a screen reader said nothing when sign-in failed, a form was saved or a request was rejected. Error lines now have role="alert" and neutral status lines role="status" across the settings, admin, auth, contact and message dialogs. On the sign-in form the error is also tied to the email and password fields with aria-invalid and aria-describedby. The compose toast is an alert for errors and a status otherwise, and new-mail notifications are announced through a persistent status region.
The loading overlay put aria-label on a plain div, which is ignored, and left its icon image readable after loading. It is now a status region that says "Loading" only while it is visible, and the logo is decorative.
Names and labels: the "open booking page" button contained a link and had no name (now a single link with a name), the import file input was not tied to its label, the AWS and Resend key fields only had placeholders, the QR code has a text alternative, the quoted-text toggles in the reader and composer are named (the reader's was hard-coded English), the composer link field has a label, the "Move to" button no longer overrides its visible text with a different name, and the snooze presets report which one is selected. The drive sort buttons no longer carry aria-sort, which is not allowed on a button, and its rows no longer claim role="row" outside a grid.
Identity fields in the profile, forwarding and sign-up forms get autocomplete tokens, and the domain field no longer says it is a URL.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
The command palette and the shortcuts help were plain divs: no dialog role, no label, focus stayed on the page behind them, Tab walked out, and closing did not return focus. The palette now is a labelled modal dialog with a combobox input, a listbox of options and aria-activedescendant, and keeps the highlighted option in view. The help dialog gets role="dialog", a label from its heading, focus on its close button, Escape and "?" to close, and a Tab trap. Both give focus back to what opened them. The shared logic is in use-dialog-focus.ts.
The "More actions" menus on a message and on a conversation card, and the account menu, closed only on an outside click. Escape now closes them and returns focus to their button, and moving focus out of them closes them too (use-dismissible.ts).
The composer did not take focus when opened and did not give it back. A new message now focuses the recipients field, an opened draft focuses the body, closing returns focus to the button that opened it, and the floating composer is a labelled region.
Tooltips blurred whatever you had activated, so keyboard users lost their place after every icon button. They no longer do, and Escape dismisses them.
Single-key shortcuts no longer fire while focus is inside a dialog, menu or listbox.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
The link that opens a message in the wide list used display: contents, so it had no box and could not take focus. Tab went through the checkbox, star and row buttons of every message but never to the message itself, which left keyboard users unable to open mail. The star button also sat inside that link, which is nested interactive content.
The link now lives on the subject and stretches over the row with a pseudo-element, so clicking anywhere on the row still opens the message. The checkbox, star and hover actions sit above it. The focus ring is drawn around the whole row, because the subject's own outline was clipped by truncate. The rows also get list and listitem roles, the open message is marked aria-current, unread rows say so to screen readers, and the checkbox is named after the sender like in the compact layout. The star keeps one name and reports its state with aria-pressed.
Every layout gets a "Skip to main content" link as its first tab stop, pointing at the main landmark. It stays hidden until it is focused. The two new strings are in all catalogs.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Moving a snoozed message to the Inbox only set its status to received, which it already was, and left snoozedUntil alone. The Snoozed list keeps showing messages whose snooze has not run out, and the Inbox hides them, so nothing visible happened. Moving to the Inbox now clears the snooze.
The toolbar also left "Inbox" out of the move menu whenever it did not know the folder, which is always the case in a custom folder, so mail could not be moved back to the Inbox from there. It is only hidden in the Inbox itself now.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
useHotkeys pushed every keydown into the sequence buffer and only matched when the whole buffer equalled a shortcut. Two things broke that: the Shift keydown of "g" then "!" put "shift" in the buffer (so "g !", go to Spam, never matched), and any key pressed within 800 ms before a sequence (for example "j" and then "g i") left a stale entry in front of it.
Modifier keys no longer enter the buffer, and a sequence now matches when it ends the buffer. A single key still only matches as the whole buffer, so lone shortcuts behave as before. The matching moved to use-hotkeys-utils.ts with a small test.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Archive, trash, spam, move and delete from the message list did nothing visible when the request failed: the toolbar buttons and the row buttons let the error go unhandled, so the message simply stayed where it was. The list now shows a dismissing alert with the existing "Could not update message" text, for both the bulk toolbar and the row actions.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Actions on a single row (the hover buttons and swipe) took the message out of the list but left it in the selection. The toolbar kept saying "1 selected" while nothing was ticked, and a bulk action then ran on a message that was no longer there, for example moving a message you had just trashed to Archive. The selection is now trimmed to the rows that are still in the list.
Archiving or trashing every row on the last page left the list on an offset past the end: it showed the empty-folder text while Previous page was the only way back. When a page other than the first comes back empty, the list now goes to the last page that has messages.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
* Close the composer after a message is sent
After a successful send the composer kept its window open, emptied, and the "Message sent" toast lived inside it. The popup now closes (the full-page composer goes to Sent) and the confirmation is shown by FloatingComposer through the compose context, so it stays visible once the composer is gone. It is a status region so screen readers announce it.
Sending also cancels the pending autosave and invalidates in-flight ones. Before, a save that fired during the send could recreate the message as a draft, which then sat in Drafts next to the sent copy.
* Keep the composer usable when a request fails, and keep what was typed
Sending while offline, or getting a non-JSON error page back, threw before the busy flag was cleared. The button stayed on "Preparing…" and disabled, with no message. A failed request now shows the send error and frees the form.
Discarding had the same problem: the delete button stayed disabled forever after a network error, and a draft that was already gone (404) could not be discarded from the full-page composer. A 404 now counts as deleted.
The draft is saved 900 ms after you stop typing, and closing the composer or opening another one within that time dropped the pending save, so a message typed and closed quickly was lost. The pending save now runs when the composer unmounts. Send and discard still cancel it.
---------
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
toggleMessageStar returns { starred }, but the card put that whole object into its boolean state. The object is always truthy, so the first click filled the star and every click after that kept it filled until the page was reloaded. Use the starred flag instead. This also clears the type error tsc reported on that line.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Marking a message as spam, moving it to Trash or archiving it from the reading view took you to the Spam, Trash or Archive folder. You now go back to the list you opened the message from, so you can keep working through it. The same goes for blocking a sender, which always jumped to Trash.
getMessageActionRedirect takes the list's href instead of a direction, and the message actions read it from the list visibility context the folder pages already provide, so custom folders work too.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
After a successful send the composer kept its window open, emptied, and the "Message sent" toast lived inside it. The popup now closes (the full-page composer goes to Sent) and the confirmation is shown by FloatingComposer through the compose context, so it stays visible once the composer is gone. It is a status region so screen readers announce it.
Sending also cancels the pending autosave and invalidates in-flight ones. Before, a save that fired during the send could recreate the message as a draft, which then sat in Drafts next to the sent copy.
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
* Mark the fixture admin as primary admin in the account tests
Creating accounts needs a primary admin or an admin with user management rights since migration 0050, but the fixture inserted its admin without is_primary_admin, so every create call got a 403.
* Let the agent tool test call mutating tools without chat approval
In chat, mark_email_read, move_email and discard_draft now return pending_approval instead of acting. The test still expected the change to happen right away. It now checks that chat only asks, and runs the direct calls with the mcp origin.
* Test the migrated schema instead of the removed bootstrap SQL
The old test read MIGRATION_NAMES and INITIAL_SCHEMA_SQL out of src/lib/setup/migration.ts, which now just runs the migration files. The replacement applies drizzle/migrations to an empty database, checks that every file is recorded and not re-applied, and keeps the mailbox, license and spam table checks.
* Expect the shortened pt-BR inbox label in the i18n tests
6978c01 changed navigation.inbox from "Caixa de entrada" to "Entrada" in pt-BR, but four tests still expected the old label.
---------
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
* Render the branded page title on the server
The root layout sent a fixed "Mailflare" title and the browser swapped in the configured app name after the page loaded. Screen readers announced both. The title now comes from the branding settings in the first response, and the branding is passed to the provider as its initial value so the name and the icon do not flash their defaults first.
The inbox page also kept its own "Inbox (n)" title after leaving it, so the sign-in page after a logout still read as the inbox. It now puts the previous title back when it unmounts.
* Stub the branding lookup in the layout i18n test
The root layout now reads the app name from the server, which pulls in the Workers runtime. The i18n test bundles the layout with stubs for its server-only imports, so add one for the branding helper too.
---------
Co-authored-by: Can Kirca <9063990+cankirca@users.noreply.github.com>
Saving credentials that validate against AWS but carry no SES permissions
answers 400 with "Attach the policy shown below", yet the panel rendered
nothing below it. Two causes: the 400 body carried the report but not the
policy, and requestJson discarded the parsed body on a rejected response,
so the report never reached the panel. The block was also gated on
`configured && !editing`, which is exactly the state a refused save leaves.
The 400 now carries the policy, requestJson throws an ApiError that keeps
the parsed body, and the panel keeps a refused report so the missing
permissions and the policy render under the message, expanded. A later
successful save, re-check, replace or remove clears it.
Refs #79
Resolve conflicts in favor of main; re-apply Web Push on top.
Renumber push_subscriptions migration 0032 -> 0054 (0032 now taken).
Skip push for agent_draft notifications.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Add a General section to Settings > Account holding language and time zone,
render the homepage language selector as a compact text label sized to its
value, drop the sidebar language field, and document localization.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Add a General section to Settings > Account holding language and time zone,
render the homepage language selector as a compact text label sized to its
value, drop the sidebar language field, and document localization.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>