Files
webmail/lib/settings-schema.ts
T

528 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The settings a plugin may change for the user (app.setSettings, permission
// settings:change), with where each one is shown and which values it may
// take. Built from the settings pages: the label of each row, its page and
// the labels of its values are the page's own locale keys, so the assistant
// asks "Time format: 12-hour → 24-hour" in the words the user sees.
//
// Everything not listed here is refused. What stays out on purpose (privacy,
// anything that sends mail or data elsewhere, deleting, free text that goes
// into sent mail, maps keyed by server ids) is listed in DENIED_SETTINGS, so
// a test can tell a forgotten key from a refused one.
//
// The run-time checks (an admin lock, a hidden row, a restriction's values,
// the feature gate that shows the row) live with the caller
// (lib/plugin-sandbox/assistant/c.settings.ts); this file is the table and
// the pure value checks.
import type { SettingsSearchTab } from '@/lib/settings-search';
import type { FeatureGates } from '@/lib/admin/types';
import {
ACCESSIBILITY_ALLOWED,
FONT_SIZES,
MAIL_MIN_FONT_SIZES,
MAIL_TEXT_SCALES,
READ_ALOUD_RATES,
TOAST_DURATIONS,
UNLOCKABLE_SETTING_KEYS,
} from '@/lib/accessibility-settings';
import { locales } from '@/i18n/routing';
/** Which store holds the setting. */
export type SettingsStoreName = 'settings' | 'theme' | 'locale' | 'calendar';
/** A value the user can pick, with the locale key of its label (null: formatted, e.g. hours). */
export interface SettingOption {
value: string | number;
label: string | null;
/** Shown as is when the page does not translate it (a font name, "16px"). Only with label null. */
text?: string;
/** The page offers this value only while the gate is on (the archive choices). */
gate?: keyof FeatureGates;
}
export type SettingKind =
/** On or off. `only` limits it to one direction (confirmDelete may only be turned on). */
| { type: 'boolean'; only?: boolean }
/** One of a fixed list. Values not in `options` are refused. */
| { type: 'choice'; options: SettingOption[] }
/** A whole hour from min to max (calendar hours). */
| { type: 'hour'; min: number; max: number }
/** 'auto' or an IANA time zone this browser knows. */
| { type: 'timeZone' }
/** '' (the browser's) or a locale the app has. */
| { type: 'locale' }
/** Days of the week 0-6, unique, at least one. */
| { type: 'days' }
/** A list of short words: trimmed, unique, no line breaks; lowercase when `lowercase` is set. */
| { type: 'words'; max: number; maxLength: number; lowercase?: boolean }
/** A list of ids from `options`, unique. */
| { type: 'list'; options: SettingOption[]; min?: number };
export interface SettingSchemaEntry {
key: string;
store: SettingsStoreName;
/** The settings page the row is on. */
page: SettingsSearchTab;
/** Locale key of the row's label. */
label: string;
kind: SettingKind;
/** The feature gate that shows the row; the change is refused while it is off. */
gate?: keyof FeatureGates;
/** The row is hidden in no-third-party mode (it fetches from elsewhere). */
thirdParty?: boolean;
/** An admin lock acts as a default for this key (UNLOCKABLE_SETTING_KEYS), so it doesn't refuse. */
preset?: boolean;
/** The label says the opposite of the value (disableThreading: "Group conversations"). */
inverted?: boolean;
}
/** Locale keys of the labels for on and off. */
export const ON_LABEL = 'settings.accessibility.on';
export const OFF_LABEL = 'settings.accessibility.off';
/**
* Pages the settings app shows only while a feature gate is on. A key on
* such a page is refused while the gate is off, on top of its own `gate`.
*/
export const PAGE_GATES: Readonly<Partial<Record<SettingsSearchTab, keyof FeatureGates>>> = {
calendar: 'calendarEnabled',
contacts: 'contactsEnabled',
files: 'filesEnabled',
};
// The ids below live in modules that import the settings store (the hover
// actions, the month view styles) or the editor (the compose sizes). They are
// copied here so this table stays free of stores; the test checks each copy
// against its source.
/** ALL_HOVER_ACTIONS in stores/settings-store.ts, with their labelKey. */
export const HOVER_ACTION_IDS = [
['delete', 'delete'], ['star', 'star'], ['markRead', 'mark_read'], ['archive', 'archive'], ['tag', 'tag'], ['spam', 'spam'],
] as const;
/** MONTH_TIMED_EVENT_STYLES in stores/settings-store.ts. */
export const MONTH_TIMED_EVENT_STYLE_IDS = ['bar', 'text', 'dot'] as const;
/** NOTIFICATION_SOUNDS in lib/notification-sound.ts (it imports lib/debug, which imports the store). */
export const NOTIFICATION_SOUND_IDS = ['default', 'cheerful', 'involved', 'swift', 'relax'] as const;
/** FONT_SIZES in components/email/font-size.ts (the composer's sizes). */
export const COMPOSE_FONT_SIZE_IDS = ['14px', '16px', '18px', '20px', '24px', '32px'] as const;
// --- Builders ----------------------------------------------------------------
type Extra = Pick<SettingSchemaEntry, 'gate' | 'thirdParty' | 'inverted'> & { store?: SettingsStoreName };
function entry(key: string, page: SettingsSearchTab, label: string, kind: SettingKind, extra: Extra = {}): SettingSchemaEntry {
const { store = 'settings', ...rest } = extra;
const out: SettingSchemaEntry = { key, store, page, label, kind, ...rest };
if (store === 'settings' && UNLOCKABLE_SETTING_KEYS.has(key)) out.preset = true;
return out;
}
function bool(key: string, page: SettingsSearchTab, label: string, extra: Extra & { only?: boolean } = {}): SettingSchemaEntry {
const { only, ...rest } = extra;
return entry(key, page, label, only === undefined ? { type: 'boolean' } : { type: 'boolean', only }, rest);
}
/** Options whose label key is `prefix` + a suffix (the value itself unless mapped). */
function opts(prefix: string, values: readonly (string | number)[], suffix: Record<string, string> = {}): SettingOption[] {
return values.map((value) => ({ value, label: prefix + (suffix[String(value)] ?? String(value)) }));
}
function choice(key: string, page: SettingsSearchTab, label: string, options: SettingOption[], extra: Extra = {}): SettingSchemaEntry {
return entry(key, page, label, { type: 'choice', options }, extra);
}
/** The values ACCESSIBILITY_ALLOWED lists for `key`, so the table can't drift from the import check. */
function allowed(key: string): readonly (string | number)[] {
const values = ACCESSIBILITY_ALLOWED[key];
if (!values) throw new Error(`settings-schema: no ACCESSIBILITY_ALLOWED list for ${key}`);
return values as readonly (string | number)[];
}
const A = 'settings.accessibility.';
const AP = 'settings.appearance.';
const EB = 'settings.email_behavior.';
const LR = 'settings.language_region.';
const CAL = 'calendar.settings.';
const DL = 'settings.downloads.';
const NO = 'settings.notifications.';
/** Off / On / System radio of the accessibility page. */
const systemToggle = (key: string) => opts(A, allowed(key));
const SWIPE_OPTIONS: SettingOption[] = [
{ value: 'none', label: `${EB}swipe_actions.none` },
{ value: 'archive', label: `${EB}swipe_actions.archive`, gate: 'archiveActionEnabled' },
{ value: 'delete', label: `${EB}swipe_actions.delete` },
{ value: 'markRead', label: `${EB}swipe_actions.mark_read` },
{ value: 'star', label: `${EB}swipe_actions.star` },
{ value: 'spam', label: `${EB}swipe_actions.spam` },
];
const ENTRIES: SettingSchemaEntry[] = [
// --- Appearance ---
choice('theme', 'appearance', `${AP}theme.label`, opts(`${AP}theme.`, ['light', 'dark', 'system']), { store: 'theme' }),
choice('density', 'appearance', `${AP}list_density.label`,
opts(`${AP}list_density.`, ['extra-compact', 'compact', 'regular', 'comfortable'], { 'extra-compact': 'extra_compact' })),
choice('interfaceLayout', 'appearance', `${AP}interface_layout.label`,
opts(`${AP}interface_layout.`, ['default', 'mountain-view'], { 'mountain-view': 'mountain_view' })),
choice('mailLayout', 'appearance', `${EB}mail_layout.label`, opts(`${EB}mail_layout.`, ['split', 'focus', 'horizontal'])),
choice('toolbarPosition', 'appearance', `${AP}toolbar_position.label`,
opts(`${AP}toolbar_position.`, ['top', 'below-subject'], { 'below-subject': 'below_subject' })),
bool('showToolbarLabels', 'appearance', `${AP}toolbar_labels.label`),
bool('colorfulSidebarIcons', 'appearance', `${AP}colorful_sidebar_icons.label`),
bool('showFolderTotalCount', 'appearance', `${AP}show_folder_total_count.label`),
bool('hideAccountSwitcher', 'appearance', `${AP}hide_account_switcher.label`),
bool('showRailAccountList', 'appearance', `${AP}show_rail_account_list.label`),
bool('proInterface', 'appearance', `${AP}pro_interface.label`),
bool('enableUnifiedMailbox', 'appearance', `${AP}unified_mailbox.label`),
bool('unifiedCrossAccount', 'appearance', `${AP}unified_mailbox.cross_account.label`, { gate: 'unifiedCrossAccountEnabled' }),
bool('includeGroupInUnified', 'appearance', `${AP}unified_mailbox.include_group.label`),
bool('enableCrossUnreadView', 'appearance', `${AP}cross_unread.label`, { gate: 'crossUnreadViewEnabled' }),
bool('enableCrossStarredView', 'appearance', `${AP}cross_starred.label`, { gate: 'crossStarredViewEnabled' }),
bool('enableCrossAllView', 'appearance', `${AP}cross_all.label`, { gate: 'crossAllViewEnabled' }),
// One radio on the page (dot / tint / none) writes both keys.
bool('tintListRowsByAccount', 'appearance', `${AP}account_marker.label`),
bool('showAccountDot', 'appearance', `${AP}account_marker.label`),
bool('showSharedMailAccounts', 'appearance', `${AP}show_shared_mail_accounts.label`),
bool('compactSharedMailboxes', 'appearance', `${AP}compact_shared_mailboxes.label`),
// The sidebar switch of the settings app; it has no page of its own.
bool('showAdvancedSettings', 'general', 'settings.advanced_switch.label'),
// --- Language and region, General ---
entry('locale', 'general', `${LR}language.label`, { type: 'locale' }, { store: 'locale' }),
choice('dateFormat', 'general', `${LR}date_format.label`, opts(`${LR}date_format.`, ['smart', 'relative', 'full'])),
choice('dateLocale', 'general', `${LR}date_locale.label`,
opts(`${LR}date_locale.`, ['auto', 'iso', 'en-GB', 'en-US'], { 'en-GB': 'dmy', 'en-US': 'mdy' })),
choice('timeFormat', 'general', `${LR}time_format.label`, opts(`${LR}time_format.`, ['12h', '24h'])),
entry('timeZone', 'general', `${LR}time_zone.label`, { type: 'timeZone' }),
choice('firstDayOfWeek', 'general', `${LR}first_day.label`,
opts('calendar.days.', [1, 6, 0], { 1: 'monday', 6: 'saturday', 0: 'sunday' })),
choice('protocolOpenMode', 'general', 'protocol_handlers.protocol_open_mode_label',
opts('protocol_handlers.protocol_open_mode_', ['new-tab', 'active-session'], { 'new-tab': 'new_tab', 'active-session': 'active_session' })),
bool('showOnboardingOnNewDevices', 'general', 'tour.show_on_new_devices_title'),
// --- Message list ---
bool('disableThreading', 'message_list', `${EB}group_conversations.label`, { inverted: true }),
bool('showPreview', 'message_list', `${EB}show_preview.label`),
bool('listColumns', 'message_list', `${EB}list_columns.label`),
choice('mvInboxType', 'message_list', `${EB}inbox_type.label`,
opts(`${EB}inbox_type.`, ['default', 'unread-first'], { 'unread-first': 'unread_first' })),
// A switch on the page: on = every folder.
choice('messageListOrderScope', 'message_list', `${AP}message_list_order.scope.label`, [
{ value: 'inbox', label: OFF_LABEL },
{ value: 'all', label: ON_LABEL },
]),
bool('tintListRowsByTag', 'message_list', `${AP}tint_list_rows.label`),
bool('showVerificationCodes', 'message_list', `${EB}show_verification_codes.label`),
// Both make the server fetch the sender's domain: the assistant may only switch them off.
bool('senderFavicons', 'message_list', 'settings.advanced.sender_favicons.label', { thirdParty: true, only: false }),
bool('showAvatarsInJunk', 'message_list', 'settings.advanced.show_avatars_in_junk.label', { thirdParty: true, only: false }),
entry('hoverActions', 'message_list', `${EB}hover_actions.label`, {
type: 'list',
options: HOVER_ACTION_IDS.map(([value, labelKey]) => ({
value,
label: `${EB}hover_actions.${labelKey}`,
...(value === 'archive' ? { gate: 'archiveActionEnabled' as const } : {}),
})),
}, { gate: 'hoverActionsConfigEnabled' }),
choice('hoverActionsMode', 'message_list', `${EB}hover_actions.mode_label`,
opts(`${EB}hover_actions.mode_`, ['inline', 'floating']), { gate: 'hoverActionsConfigEnabled' }),
choice('hoverActionsCorner', 'message_list', `${EB}hover_actions.corner_label`,
opts(`${EB}hover_actions.corner_`, ['top-left', 'top-right', 'bottom-left', 'bottom-right']), { gate: 'hoverActionsConfigEnabled' }),
choice('swipeRightAction', 'message_list', `${EB}swipe_right_action.label`, SWIPE_OPTIONS),
choice('swipeLeftAction', 'message_list', `${EB}swipe_left_action.label`, SWIPE_OPTIONS),
// No "permanent": it makes every later delete final.
choice('deleteAction', 'message_list', `${EB}delete_action.label`,
opts(`${EB}delete_action.`, ['trash', 'trash-and-read'], { 'trash-and-read': 'trash_and_read' })),
choice('archiveMode', 'message_list', `${EB}archive_mode.label`, opts(`${EB}archive_mode.`, ['single', 'year', 'month']),
{ gate: 'archiveActionEnabled' }),
// Delete safety nets: a plugin may turn them on, never off.
bool('confirmBulkDelete', 'message_list', `${EB}confirm_bulk_delete.label`, { only: true }),
bool('permanentlyDeleteJunk', 'message_list', `${EB}permanently_delete_junk.label`, { only: false }),
choice('emailsPerPage', 'message_list', `${EB}emails_per_page.label`, opts(`${EB}emails_per_page.`, [10, 25, 50, 100])),
choice('searchDefaultScope', 'message_list', `${EB}search_default_scope.label`, opts(`${EB}search_default_scope.`, ['all', 'folder'])),
bool('clearSearchOnFolderChange', 'message_list', `${EB}clear_search_on_folder_change.label`),
// --- Reading ---
choice('markAsReadDelay', 'reading', `${EB}mark_read.label`,
opts(`${EB}mark_read.`, [0, 3000, 5000, -1], { 0: 'instant', 3000: 'delay_3s', 5000: 'delay_5s', '-1': 'never' })),
bool('returnToListAfterAction', 'reading', `${EB}return_to_list_after_action.label`),
choice('messageSpacing', 'reading', `${EB}message_spacing.label`, opts(`${EB}message_spacing.`, ['auto', 'always', 'edge'])),
bool('emailAlwaysLightMode', 'reading', `${EB}always_light_mode.label`),
choice('attachmentPosition', 'reading', `${EB}attachment_position.label`,
opts(`${EB}attachment_position.`, ['below-header', 'below-message'])),
choice('mailAttachmentAction', 'reading', `${EB}attachment_click_action.label`,
opts(`${EB}attachment_click_action.`, ['preview', 'download'])),
bool('attachmentImagePreviewsEnabled', 'reading', `${EB}attachment_image_previews.label`),
bool('hideInlineImageAttachments', 'reading', `${EB}hide_inline_image_attachments.label`),
choice('filenameSpaceReplacement', 'reading', `${DL}spaces.label`, opts(`${DL}spaces.`, ['keep', 'underscore', 'dash'])),
bool('filenameLowercase', 'reading', `${DL}lowercase.label`),
bool('filenameStripDiacritics', 'reading', `${DL}strip_diacritics.label`),
bool('filenameCollapseSeparators', 'reading', `${DL}collapse_separators.label`),
// No "trash": it deletes the exported mail.
choice('postExportAction', 'reading', `${DL}after_export.label`, [
{ value: 'keep', label: `${DL}after_export.keep` },
{ value: 'archive', label: `${DL}after_export.archive`, gate: 'archiveActionEnabled' },
]),
// --- Composing ---
bool('plainTextMode', 'composing', `${EB}plain_text_mode.label`),
bool('recipientMentionsEnabled', 'composing', `${EB}recipient_mentions.label`),
choice('mentionAddsRecipientTo', 'composing', `${EB}mention_adds_recipient.label`,
opts(`${EB}mention_adds_recipient.`, ['to', 'cc', 'off'])),
bool('rtlEditingSupport', 'composing', `${EB}rtl_editing.label`),
bool('quickReplyEnabled', 'composing', `${EB}quick_reply.label`),
bool('autoSelectReplyIdentity', 'composing', `${EB}auto_select_reply_identity.label`),
// The page shows it only while the composer may rewrite From.
choice('replyIdentityMatch', 'composing', `${EB}reply_identity_match.label`,
opts(`${EB}reply_identity_match.`, ['exact', 'domain']), { gate: 'customFromEnabled' }),
choice('signaturePosition', 'composing', `${EB}signature_position.label`,
opts(`${EB}signature_position.`, ['above_quote', 'below_quote'])),
bool('signatureNewMailEnabled', 'composing', `${EB}signature_new_mail.label`),
bool('signatureReplyForwardEnabled', 'composing', `${EB}signature_reply_forward.label`),
bool('signatureSeparatorEnabled', 'composing', `${EB}signature_separator.label`),
// 10/30/60 read "{seconds} seconds" (send_delay.seconds): formatted.
choice('sendDelaySeconds', 'composing', `${EB}send_delay.label`, [
{ value: 0, label: `${EB}send_delay.off` },
{ value: 10, label: null },
{ value: 30, label: null },
{ value: 60, label: null },
]),
bool('sendConfirmation', 'accessibility', `${A}confirm_send.label`),
bool('attachmentReminderEnabled', 'composing', `${EB}attachment_reminder.label`),
// The page trims and lowercases what the user types.
entry('attachmentReminderKeywords', 'composing', `${EB}attachment_reminder.keywords_label`,
{ type: 'words', max: 50, maxLength: 40, lowercase: true }),
bool('emptySubjectWarningEnabled', 'composing', `${EB}empty_subject_warning.label`),
bool('placeholderWarningEnabled', 'composing', `${EB}placeholder_warning.label`),
bool('requestReadReceiptDefault', 'composing', `${EB}request_read_receipt.label`),
// No "always": that sends a receipt mail by itself.
choice('readReceiptResponse', 'composing', `${EB}read_receipt_response.label`,
opts(`${EB}read_receipt_response.`, ['ask', 'never'])),
// --- Notifications ---
bool('emailNotificationsEnabled', 'notifications', `${NO}email.enabled`),
bool('emailNotificationSound', 'notifications', `${NO}email.sound`),
choice('notificationSoundChoice', 'notifications', `${NO}sound_selection.choose`, opts(`${NO}sounds.`, NOTIFICATION_SOUND_IDS)),
bool('pushNotifyInboxOnly', 'notifications', `${NO}email.inbox_only`),
bool('faviconUnreadBadge', 'notifications', `${AP}favicon_unread_badge.label`),
bool('calendarNotificationsEnabled', 'notifications', `${NO}calendar.enabled`),
bool('calendarNotificationSound', 'notifications', `${NO}calendar.sound`),
bool('doNotDisturb', 'accessibility', `${A}do_not_disturb.label`),
bool('visualAlerts', 'accessibility', `${A}visual_alerts.label`),
// Seconds read "{seconds} seconds": formatted. The admin minimum is checked at run time.
choice('toastDuration', 'accessibility', `${A}toast_duration.label`,
TOAST_DURATIONS.map((value) => ({ value, label: value === 0 ? `${A}toast_duration.until_closed` : null }))),
// --- Calendar ---
choice('viewMode', 'calendar', `${CAL}default_view`, opts('calendar.views.', ['month', 'week', 'day', 'agenda']), { store: 'calendar' }),
bool('calendarInvitationParsingEnabled', 'calendar', `${CAL}invitation_parsing`),
bool('showTimeInMonthView', 'calendar', `${CAL}show_time_in_month_view`),
bool('calendarTintedEvents', 'calendar', `${CAL}tinted_events`),
choice('calendarMonthTimedEvents', 'calendar', `${CAL}month_timed_events`, opts(`${CAL}month_timed_events_`, MONTH_TIMED_EVENT_STYLE_IDS)),
bool('showWeekNumbers', 'calendar', `${CAL}show_week_numbers`),
bool('calendarLimitHours', 'calendar', `${CAL}limit_hours`),
// End after start is checked at run time, across both keys.
entry('calendarDayStartHour', 'calendar', `${CAL}visible_hours`, { type: 'hour', min: 0, max: 23 }),
entry('calendarDayEndHour', 'calendar', `${CAL}visible_hours`, { type: 'hour', min: 1, max: 24 }),
bool('calendarHideNonWorkingDays', 'calendar', `${CAL}hide_non_working_days`),
entry('calendarWorkingDays', 'calendar', `${CAL}working_days`, { type: 'days' }),
bool('calendarFreeScroll', 'calendar', `${CAL}calendar_free_scroll`),
choice('calendarHoverPreview', 'calendar', `${CAL}hover_preview`,
opts(`${CAL}hover_preview_`, ['instant', 'delay-500ms', 'delay-1s', 'delay-2s', 'off'],
{ 'delay-500ms': 'delay_500ms', 'delay-1s': 'delay_1s', 'delay-2s': 'delay_2s' })),
bool('showBirthdayCalendar', 'calendar', `${CAL}show_birthday_calendar`),
bool('enableCalendarTasks', 'calendar', `${CAL}enable_tasks`, { gate: 'calendarTasksEnabled' }),
bool('showTasksOnCalendar', 'calendar', `${CAL}show_tasks_on_calendar`, { gate: 'calendarTasksEnabled' }),
// --- Contacts ---
bool('groupContactsByLetter', 'contacts', 'settings.contacts.group_by_letter_label'),
bool('sortContactsByLastName', 'contacts', 'settings.contacts.sort_by_last_name_label'),
bool('showContactsDirectory', 'contacts', 'settings.contacts.show_directory_label'),
// --- Accessibility ---
bool('showAccessibilityButton', 'accessibility', `${A}quick_button.label`),
choice('fontSize', 'accessibility', `${A}text_size.label`, opts(`${A}text_size.`, FONT_SIZES)),
choice('appFont', 'accessibility', `${A}font.label`, [
{ value: 'default', label: `${A}font.default` },
{ value: 'atkinson', label: null, text: 'Atkinson Hyperlegible' },
{ value: 'lexend', label: null, text: 'Lexend' },
{ value: 'opendyslexic', label: null, text: 'OpenDyslexic' },
]),
choice('plainTextFont', 'accessibility', `${A}plain_text_font.label`, opts(`${A}plain_text_font.`, ['sans', 'mono'])),
choice('lineSpacing', 'accessibility', `${A}line_spacing.label`, opts(`${A}spacing.`, allowed('lineSpacing'))),
choice('letterSpacing', 'accessibility', `${A}letter_spacing.label`, opts(`${A}spacing.`, allowed('letterSpacing'))),
choice('wordSpacing', 'accessibility', `${A}word_spacing.label`, opts(`${A}spacing.`, allowed('wordSpacing'))),
choice('paragraphSpacing', 'accessibility', `${A}paragraph_spacing.label`, opts(`${A}spacing.`, allowed('paragraphSpacing'))),
choice('readingWidth', 'accessibility', `${A}reading_width.label`, opts(`${A}reading_width.`, allowed('readingWidth'))),
bool('readingMask', 'accessibility', `${A}reading_mask.label`),
bool('plainLanguage', 'accessibility', `${A}plain_language.label`),
choice('contrast', 'accessibility', `${A}contrast.label`, [
{ value: 'system', label: `${A}system` },
{ value: 'more', label: `${A}contrast.more` },
{ value: 'normal', label: `${A}contrast.normal` },
]),
choice('reduceTransparency', 'accessibility', `${A}transparency.label`, systemToggle('reduceTransparency')),
bool('underlineLinks', 'accessibility', `${A}underline_links.label`),
bool('showColorNames', 'accessibility', `${A}color_names.label`),
bool('colorBlindPalette', 'accessibility', `${A}cvd_palette.label`),
choice('reduceMotion', 'accessibility', `${A}reduce_motion.label`, systemToggle('reduceMotion')),
choice('pauseAnimatedImages', 'accessibility', `${A}pause_images.label`, systemToggle('pauseAnimatedImages')),
bool('singleKeyShortcuts', 'accessibility', `${A}single_key_shortcuts.label`),
choice('focusRing', 'accessibility', `${A}focus_ring.label`, opts(`${A}focus_ring.`, allowed('focusRing'))),
bool('focusAlwaysVisible', 'accessibility', `${A}focus_always.label`),
choice('largeTargets', 'accessibility', `${A}large_targets.label`, systemToggle('largeTargets')),
bool('largeCursor', 'accessibility', `${A}large_cursor.label`),
bool('openOnHover', 'accessibility', `${A}open_on_hover.label`),
bool('showRowActionButton', 'accessibility', `${A}row_action_button.label`),
bool('nativeScrollbars', 'accessibility', `${A}native_scrollbars.label`),
choice('mailViewMode', 'accessibility', `${A}mail_view.label`, opts(`${A}mail_view.`, allowed('mailViewMode'))),
// Percentages: formatted, except 0 ("follow the interface").
choice('mailTextScale', 'accessibility', `${A}mail_text_size.label`,
MAIL_TEXT_SCALES.map((value) => ({ value, label: value === 0 ? `${A}mail_text_size.follow` : null }))),
// "{size} px": formatted, except 0 ("none").
choice('mailMinFontSize', 'accessibility', `${A}mail_min_font_size.label`,
MAIL_MIN_FONT_SIZES.map((value) => ({ value, label: value === 0 ? `${A}mail_min_font_size.none` : null }))),
choice('mailOverrideStyles', 'accessibility', `${A}mail_override.label`,
opts(`${A}mail_override.`, allowed('mailOverrideStyles'), { 'high-contrast': 'high_contrast' })),
bool('mailFitWidth', 'accessibility', `${A}mail_fit_width.label`),
// "1.25×": formatted.
choice('readAloudRate', 'accessibility', `${A}read_aloud_rate.label`, READ_ALOUD_RATES.map((value) => ({ value, label: null }))),
// Only the fonts and sizes the page offers: a free CSS value must not reach the composer.
choice('composeFontFamily', 'accessibility', `${A}compose_font.label`,
opts(`${A}compose_font.`, ['', 'sans-serif', 'serif', 'monospace'], { '': 'default', 'sans-serif': 'sans', monospace: 'mono' })),
choice('composeFontSize', 'accessibility', `${A}compose_size.label`, [
{ value: '', label: `${A}compose_size.default` },
...COMPOSE_FONT_SIZE_IDS.map((size) => ({ value: size, label: null, text: size })),
]),
// A delete safety net: on only.
bool('confirmDelete', 'accessibility', `${A}confirm_delete.label`, { only: true }),
bool('confirmArchive', 'accessibility', `${A}confirm_archive.label`),
choice('unreadCountStyle', 'accessibility', `${A}unread_counts.label`, opts(`${A}unread_counts.`, allowed('unreadCountStyle'))),
bool('announceNewMail', 'accessibility', `${A}announce_new_mail.label`),
bool('announceRowPreview', 'accessibility', `${A}announce_preview.label`),
bool('simpleMode', 'accessibility', `${A}simple_mode.label`),
bool('simpleModePagedList', 'accessibility', `${A}simple_mode_paged.label`),
];
/** The settings a plugin may change, by key. */
export const SETTINGS_SCHEMA: Readonly<Record<string, SettingSchemaEntry>> = Object.freeze(
Object.fromEntries(ENTRIES.map((e) => [e.key, e])),
);
/**
* Settings refused on purpose (see the file header), grouped by why.
*/
export const DENIED_SETTINGS: readonly string[] = [
// Privacy: loads remote content, or says who may.
'externalContentPolicy', 'trustedSenders', 'trustedSendersAddressBook',
// Free text that ends up in file names or in every sent mail.
'emailDownloadTemplate', 'attachmentDownloadTemplate', 'bundleDownloadTemplate', 'signatureDetails',
// Never read by the app (defaultReplyMode), or on the account page (the delimiter).
'defaultReplyMode', 'subAddressDelimiter',
// Sends push metadata to another server.
'pushRelayUrl',
// Turning proofreading on sends what the user writes to a checker plugin.
'proofreading', 'proofreadingPlugins',
// A browser voice id the model can't know.
'readAloudVoice',
// Maps and lists keyed by server ids.
'allMailFolderIds', 'hiddenSharedMailAccounts', 'notificationMutes', 'hiddenCompanyCalendars', 'sharedCalendarColors',
'folderIcons', 'folderCountModes', 'preferredIdentityIds',
// App bookkeeping and UI state, not preferences.
'onboardingCompleted', 'tourCompleted', 'mvUnreadSectionCollapsed', 'mvEverythingElseCollapsed',
'vacationCalendarBlocks', 'explicitSettings', 'proofreadingNoticeSeen',
// Security, debugging and sync.
'sessionTimeout', 'debugMode', 'debugCategories', 'settingsSyncDisabled',
// Tags (they have their own keywords.* actions), filters and add-ons.
'emailKeywords', 'nestedTags', 'expandedFilterView', 'sidebarApps', 'keepAppsLoaded',
];
/**
* Allowed in principle but not built yet: each needs more than a value
* check.
* - messageListOrder: a list of sort levels (sanitizeSortLevels).
* - filesSettings: an object; each sub-key would need its own row.
* - activeThemeId (theme store): an installed theme id; disabled and forced themes.
* - birthdayCalendarColor: a colour, set in the calendar app, not in Settings.
* - vacationBlockCalendar: the page also adds or removes the busy event
* (vacationBlockChanged); updateSetting alone would not.
* - writingLanguage: a language from lib/proofreading/languages, and the
* organisation policy may pin it.
*/
export const NOT_YET_SETTINGS: readonly string[] = [
'messageListOrder', 'filesSettings', 'activeThemeId', 'birthdayCalendarColor', 'vacationBlockCalendar',
'writingLanguage',
];
/** Line breaks, tabs and other control characters. */
const CONTROL_CHARS = /\p{Cc}/u;
/** An IANA zone id this runtime knows, spelled as Intl spells it ("Europe/Berlin", "UTC"). */
function isKnownTimeZone(value: string): boolean {
if (!value || value.length > 64 || !/^[A-Za-z][A-Za-z0-9_+\-/]*$/.test(value)) return false;
try {
const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] };
if (intl.supportedValuesOf?.('timeZone').includes(value)) return true;
} catch {
// fall through to the DateTimeFormat check
}
try {
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone === value;
} catch {
return false;
}
}
function isUniqueList(value: unknown[]): boolean {
return new Set(value).size === value.length;
}
/** Whether `value` is one the setting may take (the table's limits only; restrictions are checked at run time). */
export function isAllowedSettingValue(entry: SettingSchemaEntry, value: unknown): boolean {
const kind = entry.kind;
switch (kind.type) {
case 'boolean':
return typeof value === 'boolean' && (kind.only === undefined || value === kind.only);
case 'choice':
return kind.options.some((option) => option.value === value);
case 'hour':
return typeof value === 'number' && Number.isInteger(value) && value >= kind.min && value <= kind.max;
case 'timeZone':
return typeof value === 'string' && (value === 'auto' || isKnownTimeZone(value));
case 'locale':
return typeof value === 'string' && (value === '' || value === 'auto' || (locales as readonly string[]).includes(value));
case 'days':
return Array.isArray(value) && value.length >= 1 && value.length <= 7 && isUniqueList(value)
&& value.every((day) => typeof day === 'number' && Number.isInteger(day) && day >= 0 && day <= 6);
case 'words':
return Array.isArray(value) && value.length <= kind.max && isUniqueList(value)
&& value.every((word) => typeof word === 'string' && word.length >= 1 && word.length <= kind.maxLength
&& word === word.trim() && !CONTROL_CHARS.test(word) && (!kind.lowercase || word === word.toLowerCase()));
case 'list':
return Array.isArray(value) && value.length >= (kind.min ?? 0) && isUniqueList(value)
&& value.every((item) => kind.options.some((option) => option.value === item));
}
}
/**
* The locale key of the label for `value`: ON_LABEL / OFF_LABEL for a switch
* (turned round for an inverted one), the option's label for a choice, the
* "Automatic" label for timeZone 'auto'. Null when the value is formatted or
* shown as is (hours, seconds, zone ids, languages, lists).
*/
export function settingValueLabelKey(entry: SettingSchemaEntry, value: unknown): string | null {
const kind = entry.kind;
switch (kind.type) {
case 'boolean':
if (typeof value !== 'boolean') return null;
return (entry.inverted ? !value : value) ? ON_LABEL : OFF_LABEL;
case 'choice':
return kind.options.find((option) => option.value === value)?.label ?? null;
case 'timeZone':
return value === 'auto' ? `${LR}time_zone.auto` : null;
default:
return null;
}
}