mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
The AI assistant's "more actions" need host calls today's plugin API lacks. Area c of lib/plugin-sandbox/assistant adds them: - `calendar.respond(emailId, status)`, new permission calendar:respond: answers an invitation of the user's own account through lib/calendar-rsvp.ts, like the reader's banner - `calendar.deleteEvent` now returns an undo token for a single event; `calendar.undoDelete` creates it again from the host's copy - `filters.list/create/undo` over Settings > Filters' model; forward, redirect, reject, discard and keep are refused, and so are folders that are shared or not the account's own - `email.batch/undoBatch`: up to 200 messages through the store's batch functions, restore points first, one entry in the undo history - `vacation.get` also says whether the out-of-office forwards mail - `app.describeSettings`, `app.setSettings` and `app.undoSettings`, new permission settings:change, over the allow-list in lib/settings-schema.ts, refusing locked, hidden, restricted and gated settings when the change is made Undo tokens stay in the host for 30 minutes, bound to the plugin and the account. app.getSettings now shows every setting setSettings may change. settings:write's text is reworded to what it gates (tags). plugin-host-api-app-info gains a test; its other assertions are as they were.
520 lines
30 KiB
TypeScript
520 lines
30 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`),
|
||
bool('senderFavicons', 'message_list', 'settings.advanced.sender_favicons.label', { thirdParty: true }),
|
||
bool('showAvatarsInJunk', 'message_list', 'settings.advanced.show_avatars_in_junk.label', { thirdParty: true }),
|
||
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('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',
|
||
// 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',
|
||
// 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.
|
||
*/
|
||
export const NOT_YET_SETTINGS: readonly string[] = [
|
||
'messageListOrder', 'filesSettings', 'activeThemeId', 'birthdayCalendarColor', 'vacationBlockCalendar',
|
||
];
|
||
|
||
/** 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;
|
||
}
|
||
}
|