feat: let operators keep the message subject out of the tab title

Opening a message puts its subject in the browser-tab title. The title does
not stay in the tab strip: browsers keep it in history and in the
session-restore list, and screen shares show it. For mailboxes that hold
other people's correspondence that is a leak nobody audits.

A new feature gate, tabTitleSubjectEnabled ("Subject in Tab Title" under
Admin -> Policy), is on by default, so nothing changes for existing
installs. With it off, the title names the folder instead, so the tab still
says where you are. It is an admin gate rather than a user setting: whose
mail the boxes hold is a deployment decision.

The gate counts as off until the policy has loaded, so a subject never
slips into history while the setting is still unknown.

Refs #1159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oğuz Yılmaz
2026-10-07 08:26:51 +03:00
co-authored by Claude Opus 5.5
parent 13e2855484
commit 54ec8c6584
5 changed files with 28 additions and 5 deletions
+1
View File
@@ -32,6 +32,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
+5 -2
View File
@@ -944,6 +944,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [clearPendingUndoSend, pendingUndoSend]);
// Page title: "<context> - <account> - <app>" (see useDocumentTitle)
// The subject is kept out until the policy has loaded: a gate that keeps
// subjects out of browser history must not let one through while unknown.
const subjectInTabTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled('tabTitleSubjectEnabled'));
const titleContext = useMemo(() => {
// Composing email
const composer = showComposer
@@ -967,8 +970,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
mailboxLine = unreadCount > 0 ? `${mailboxName} (${unreadCount})` : mailboxName;
}
return mailTitleContext({ composer, subject, mailbox: mailboxLine });
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]);
return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle });
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]);
useDocumentTitle(titleContext);
// When this page is rendered inside the Pro shell as the Mail tab body,
+8
View File
@@ -41,4 +41,12 @@ describe('mailTitleContext', () => {
it('has nothing to add before a mailbox is selected', () => {
expect(mailTitleContext({})).toBeNull();
});
it('names the mailbox instead of the open message when the subject is kept out', () => {
expect(mailTitleContext({ ...view, composer: null }, { showSubject: false })).toBe('Inbox (3)');
});
it('still names the composer when the subject is kept out', () => {
expect(mailTitleContext(view, { showSubject: false })).toBe('Reply');
});
});
+2
View File
@@ -58,6 +58,7 @@ export interface FeatureGates {
debugModeEnabled: boolean;
folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean;
tabTitleSubjectEnabled: boolean;
filesEnabled: boolean;
contactsEnabled: boolean;
/** @deprecated Folded into `crossAllViewEnabled`; normalized forward on policy load. */
@@ -85,6 +86,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
debugModeEnabled: true,
folderIconsEnabled: true,
hoverActionsConfigEnabled: true,
tabTitleSubjectEnabled: true,
filesEnabled: true,
contactsEnabled: true,
allMailViewEnabled: false,
+12 -3
View File
@@ -21,7 +21,16 @@ export interface MailTitleView {
mailbox?: string | null;
}
/** The context part of the mail view's tab title: composer, then message, then mailbox. */
export function mailTitleContext(view: MailTitleView): string | null {
return view.composer || view.subject || view.mailbox || null;
/**
* The context part of the mail view's tab title: composer, then message, then
* mailbox. The subject does not stay in the tab strip - browsers keep it in
* history and in the session-restore list, and screen shares show it - so an
* operator can keep it out (`tabTitleSubjectEnabled`). The mailbox line is
* shown instead, so the tab still says where you are. (#1159)
*/
export function mailTitleContext(
view: MailTitleView,
{ showSubject = true }: { showSubject?: boolean } = {},
): string | null {
return view.composer || (showSubject ? view.subject : null) || view.mailbox || null;
}