mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Offline sat in the middle of Backup & reset, privacy settings were spread
over Reading, Composing and Message list, and the version card took room
from the page list in the sidebar's footer. The search matched letters
inside words ("lock" found Time format and Send Checks) and knew no other
words for a setting ("offline", "autoreply", "logout" found nothing).
- Offline is a page in the top group: Keep mail on this device, what is
kept, Remove everything from this device, and the Outbox as its own
section. Old `/settings/backup/offline` links and the offline notice
land there.
- Privacy holds remote images and trusted senders, read receipts, sender
pictures from the web, the desktop app's usage info and crash reports,
and a link to the push relay on Notifications. Reading, Composing and
Message list keep a link row; old links to those sections land on
Privacy.
- About is the last page and holds the version card; the footer keeps the
Advanced switch.
- The search matches whole words and word starts (substrings only in
Chinese, Japanese and Thai), and each row can carry synonyms in the
user's language. A page matches by its name and keywords or when one of
its rows has every word. Account gets a Sign out row so "logout" leads
somewhere.
- The index test now also fails when a page draws a section the index
doesn't know, and a new test fails on two indexed rows with the same
name. Tests that rendered remote content and sender pictures on Reading
and Message list render Privacy instead, since that's where the rows are.
528 lines
31 KiB
TypeScript
528 lines
31 KiB
TypeScript
// 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', 'privacy', 'settings.advanced.sender_favicons.label', { thirdParty: true, only: false }),
|
||
bool('showAvatarsInJunk', 'privacy', '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', 'privacy', `${EB}request_read_receipt.label`),
|
||
// No "always": that sends a receipt mail by itself.
|
||
choice('readReceiptResponse', 'privacy', `${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;
|
||
}
|
||
}
|