Merge branch 'cu-policy2' into catch-up-2026-11

This commit is contained in:
Linus Rath
2026-10-31 15:46:19 +01:00
22 changed files with 888 additions and 191 deletions
+42 -3
View File
@@ -12,10 +12,12 @@ import {
hasFaviconFailed,
markFaviconFailed,
} from '../lib/avatar-utils';
import { bimiLogoUrl, isNoLogoImage, markBrandLogoFailed, showBrandLogo } from '../lib/avatar-bimi';
import type { ContactPhotoRef } from '../lib/contact-utils';
import { useContactPhotoSource } from '../lib/use-contact-photo';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
import { useAccountStore } from '../stores/account-store';
import { useColors } from '../theme/colors';
import { ShieldAlert } from './icons';
@@ -43,12 +45,19 @@ interface SenderAvatarProps {
* or photo (webmail #1439).
*/
senderWarning?: string | null;
/**
* The From domain of a message that passed DMARC for it (see
* brandIndicatorDomain in lib/avatar-bimi): its BIMI logo, from the
* account's webmail, comes before the favicon, under the same switch.
*/
brandDomain?: string | null;
}
// Mirrors the webmail `Avatar` priority for the subset available on native:
// sender warning → address-book photo → company favicon (if sender domain is
// sender warning → address-book photo → BIMI logo (from the account's
// webmail, `senderFavicons` on) → company favicon (if sender domain is
// non-personal and `senderFavicons` is on) → initials over HSL-hashed color.
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) {
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning, brandDomain }: SenderAvatarProps) {
const c = useColors();
const warned = !!senderWarning;
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
@@ -63,9 +72,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
const [imgError, setImgError] = React.useState(false);
// BIMI logos come from the webmail the active account signed in through.
const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null);
const [failedBrand, setFailedBrand] = React.useState<string | null>(null);
React.useEffect(() => {
setImgError(false);
setFailedBrand(null);
}, [email]);
const primaryName = (name ?? '').split(',')[0].trim();
@@ -74,9 +87,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
const faviconDomain = getFaviconDomain(email ?? undefined);
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
const brandUrl = brandDomain ? bimiLogoUrl(webmailUrl, brandDomain) : null;
const showBrand = !!brandUrl && brandUrl !== failedBrand
&& showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl });
const content = avatarContent({
senderWarning,
hasPhoto: !disableImages && !!photoSource,
showBrand,
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
});
@@ -84,6 +101,12 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
setImgError(true);
if (faviconDomain) markFaviconFailed(faviconDomain);
}, [faviconDomain]);
// No logo (or it didn't load): the favicon or initials take over.
const handleBrandFailed = React.useCallback(() => {
if (!brandUrl) return;
markBrandLogoFailed(brandUrl);
setFailedBrand(brandUrl);
}, [brandUrl]);
const containerStyle = [
styles.container,
@@ -93,7 +116,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
borderRadius: size / 2,
backgroundColor: content === 'warning'
? c.errorBg
: content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor,
: content === 'photo' ? 'transparent' : content === 'favicon' || content === 'brand' ? '#ffffff' : bgColor,
},
style,
];
@@ -127,6 +150,22 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
);
}
if (content === 'brand' && brandUrl) {
return (
<View style={containerStyle}>
<Image
source={{ uri: brandUrl }}
style={{ width: size, height: size, borderRadius: size / 2 }}
resizeMode="cover"
// The webmail answers a domain without a logo with a 1x1 PNG.
onLoad={(e) => { if (isNoLogoImage(e.nativeEvent?.source)) handleBrandFailed(); }}
onError={handleBrandFailed}
testID="sender-brand-avatar"
/>
</View>
);
}
if (content === 'favicon' && faviconDomain) {
return (
<View style={containerStyle}>
@@ -4,6 +4,8 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s
import Button from '../Button';
import { spacing } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { usePolicyStore, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
import { policyAllowsValue } from '../../lib/org-policy';
import { APP_FONTS, FONT_SIZES, useSettingsStore, type AppFont, type FontSize } from '../../stores/settings-store';
import { uiFontsBundled } from '../../theme/reading-fonts';
import { toast } from '../../stores/toast-store';
@@ -81,6 +83,16 @@ export function AccessibilitySettings() {
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const toastDuration = useSettingsStore((s) => s.toastDuration);
const sendConfirmation = useSettingsStore((s) => s.sendConfirmation);
// What the organisation pins or hides (webmail accessibility-settings.tsx):
// a minimum notification duration leaves out the shorter choices.
const policyRules = usePolicyStore((s) => s.rules);
const toastDurationLocked = useSettingLocked('toastDuration');
const sendConfirmationLocked = useSettingLocked('sendConfirmation');
const sendConfirmationHidden = useSettingHidden('sendConfirmation');
const toastChoices = React.useMemo(
() => TOAST_DURATION_CHOICES.filter((value) => value === toastDuration || policyAllowsValue('toastDuration', value, policyRules)),
[toastDuration, policyRules],
);
const confirmDelete = useSettingsStore((s) => s.confirmDelete);
const confirmArchive = useSettingsStore((s) => s.confirmArchive);
@@ -214,23 +226,27 @@ export function AccessibilitySettings() {
<SettingItem
label={t('settings.accessibility.toast_duration.label', 'Notification duration')}
description={t('settings.accessibility.toast_duration.description', 'How long messages such as "Moved to Archive" with their Undo button stay on screen.')}
locked={toastDurationLocked}
>
<Select
value={String(toastDuration)}
onChange={(v) => update('toastDuration', Number(v))}
options={TOAST_DURATION_CHOICES.map((value) => ({ value: String(value), label: durationLabel(value) }))}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
description={t('settings.accessibility.confirm_send.description', 'Asks before a message is sent.')}
>
<ToggleSwitch
checked={sendConfirmation}
onChange={(v) => update('sendConfirmation', v)}
accessibilityLabel={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
options={toastChoices.map((value) => ({ value: String(value), label: durationLabel(value) }))}
/>
</SettingItem>
{!sendConfirmationHidden && (
<SettingItem
label={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
description={t('settings.accessibility.confirm_send.description', 'Asks before a message is sent.')}
locked={sendConfirmationLocked}
>
<ToggleSwitch
checked={sendConfirmation}
onChange={(v) => update('sendConfirmation', v)}
accessibilityLabel={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
/>
</SettingItem>
)}
<SettingItem
label={t('settings.accessibility.confirm_delete.label', 'Confirm before deleting')}
description={t('settings.accessibility.confirm_delete.description', 'Asks before a message is deleted, also for single messages.')}
+26 -16
View File
@@ -7,6 +7,7 @@ import { useSettingsStore, type ThemeMode, type Density } from '../../stores/set
import { useLocaleStore } from '../../stores/locale-store';
import { setPendingSettingsTab } from '../../navigation/pending-settings-tab';
import { densityPreviewConfig } from './density-preview';
import { useSettingHidden, useSettingLocked } from '../../stores/policy-store';
type Theme = ThemeMode;
@@ -81,6 +82,10 @@ export function AppearanceSettings() {
const theme = useSettingsStore((s) => s.theme);
const t = useLocaleStore((s) => s.t);
const setTheme = useSettingsStore((s) => s.setTheme);
// The organisation's theme policy can pin the colour mode (#1280).
const themeLocked = useSettingLocked('theme');
const densityLocked = useSettingLocked('density');
const densityHidden = useSettingHidden('density');
const density = useSettingsStore((s) => s.density);
const setDensity = useSettingsStore((s) => s.setDensity);
const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels);
@@ -99,7 +104,7 @@ export function AppearanceSettings() {
title={t('settings.appearance.title', "Appearance")}
description={t('settings.appearance.description_mobile', 'Customize how the app looks')}
>
<SettingItem label={t('settings.appearance.theme.label', "Theme")} description={t('settings.appearance.theme.description', "Choose your preferred color scheme")}>
<SettingItem label={t('settings.appearance.theme.label', "Theme")} description={t('settings.appearance.theme.description', "Choose your preferred color scheme")} locked={themeLocked}>
<RadioGroup
value={theme}
onChange={(v) => setTheme(v as Theme)}
@@ -119,21 +124,25 @@ export function AppearanceSettings() {
link="page"
/>
<View>
<SettingItem label={t('settings.appearance.list_density.label', "Density")} description={t('settings.appearance.list_density.description', "Control spacing and padding across the UI")} noBorder />
<RadioGroup
value={density}
onChange={(v) => setDensity(v as Density)}
options={[
{ value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") },
{ value: 'compact', label: t('settings.appearance.list_density.compact', "Compact") },
{ value: 'regular', label: t('settings.appearance.list_density.regular', "Regular") },
{ value: 'comfortable', label: t('settings.appearance.list_density.comfortable', "Comfortable") },
]}
/>
<DensityPreview density={density} />
<View style={styles.divider} />
</View>
{!densityHidden && (
<View>
<SettingItem label={t('settings.appearance.list_density.label', "Density")} description={t('settings.appearance.list_density.description', "Control spacing and padding across the UI")} noBorder locked={densityLocked} />
<View pointerEvents={densityLocked ? 'none' : 'auto'} style={densityLocked && styles.lockedControl}>
<RadioGroup
value={density}
onChange={(v) => setDensity(v as Density)}
options={[
{ value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") },
{ value: 'compact', label: t('settings.appearance.list_density.compact', "Compact") },
{ value: 'regular', label: t('settings.appearance.list_density.regular', "Regular") },
{ value: 'comfortable', label: t('settings.appearance.list_density.comfortable', "Comfortable") },
]}
/>
</View>
<DensityPreview density={density} />
<View style={styles.divider} />
</View>
)}
<SettingItem label={t('settings.appearance.toolbar_labels.label', "Show Toolbar Labels")} description={t('settings.appearance.toolbar_labels.description_mobile', "Display text labels next to toolbar icons.")}>
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
@@ -191,6 +200,7 @@ function makeStyles(c: ThemePalette) {
fontSize: 11,
color: c.textMuted,
},
lockedControl: { opacity: 0.6 },
divider: {
height: 1,
backgroundColor: c.border,
+18 -10
View File
@@ -7,6 +7,7 @@ import {
type TimeFormat,
} from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { usePolicyFeature, useSettingLocked } from '../../stores/policy-store';
import { AUTO_TIME_ZONE, getDeviceTimeZone, isValidTimeZone } from '../../lib/calendar-timezone';
import { deviceSyncAvailable } from '../../device-sync/app/available';
import { CALENDAR_AUTHORITY } from '../../device-sync/types';
@@ -49,6 +50,10 @@ export function CalendarSettings() {
const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar);
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
// An organisation can turn tasks off: the option goes, as in the webmail
// (calendar-settings.tsx), and the policy pins it off.
const tasksAllowed = usePolicyFeature('calendarTasksEnabled');
const tasksLocked = useSettingLocked('enableCalendarTasks');
// Android with the native module only (#34).
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
@@ -185,17 +190,20 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
>
<ToggleSwitch
checked={tasksEnabled}
onChange={(v) => update('enableCalendarTasks', v)}
/>
</SettingItem>
{tasksAllowed && (
<SettingItem
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
locked={tasksLocked}
>
<ToggleSwitch
checked={tasksEnabled}
onChange={(v) => update('enableCalendarTasks', v)}
/>
</SettingItem>
)}
{tasksEnabled && (
{tasksAllowed && tasksEnabled && (
<SettingItem
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
description={t(
+12 -1
View File
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingLocked } from '../../stores/policy-store';
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
import { RADIO_GROUP_A11Y, radioOptionA11y } from '../radio-a11y';
@@ -24,6 +25,10 @@ export function ComposingSettings() {
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
// Settings the organisation can pin (webmail lib/admin/restriction-keys.ts).
const autoSelectReplyIdentityLocked = useSettingLocked('autoSelectReplyIdentity');
const replyIdentityMatchLocked = useSettingLocked('replyIdentityMatch');
const plainTextModeLocked = useSettingLocked('plainTextMode');
const signaturePosition = useSettingsStore((s) => s.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
@@ -117,6 +122,7 @@ export function ComposingSettings() {
<SettingItem
label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")}
description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "Replies always come from the identity a message was sent to. With this on, a reply to another address on your domains (a catch-all alias) is sent from that address too.")}
locked={autoSelectReplyIdentityLocked}
>
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
@@ -127,7 +133,10 @@ export function ComposingSettings() {
<Text style={styles.subDescription}>
{t('settings.email_behavior.reply_identity_match.description', 'Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.')}
</Text>
{segmented(REPLY_IDENTITY_MATCHES, replyIdentityMatch, (v) => updateSetting('replyIdentityMatch', v))}
{replyIdentityMatchLocked && <Text style={styles.subDescription}>{t('settings.org_locked', 'Set by your organisation')}</Text>}
<View pointerEvents={replyIdentityMatchLocked ? 'none' : 'auto'} style={replyIdentityMatchLocked && styles.lockedControl}>
{segmented(REPLY_IDENTITY_MATCHES, replyIdentityMatch, (v) => updateSetting('replyIdentityMatch', v))}
</View>
</View>
)}
@@ -137,6 +146,7 @@ export function ComposingSettings() {
// the webmail's description (sending only) leaves out; same text as
// under Reading.
description={t('settings.email_behavior.plain_text_mode.description_mobile', 'Compose and read in plain text only.')}
locked={plainTextModeLocked}
>
<ToggleSwitch checked={plainTextMode} onChange={(v) => updateSetting('plainTextMode', v)} />
</SettingItem>
@@ -271,6 +281,7 @@ function makeStyles(c: ThemePalette) {
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: c.text },
subDescription: { ...typography.caption, color: c.mutedForeground },
lockedControl: { opacity: 0.6 },
segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 },
segment: {
flex: 1,
@@ -13,6 +13,7 @@ import { useHasContacts } from '../../lib/capabilities';
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { removeTrustedSenderWithUndo } from '../../lib/settings-undo';
import { useLocaleStore } from '../../stores/locale-store';
import { usePolicyFeature, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
import { ToastHost } from '../ToastHost';
@@ -31,6 +32,12 @@ export function ContentSendersSettings() {
const t = useLocaleStore((s) => s.t);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const setExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
// The organisation can take the option away or pin it (webmail
// reading-settings.tsx).
const externalContentAllowed = usePolicyFeature('externalContentEnabled');
const externalContentHidden = useSettingHidden('externalContentPolicy');
const externalContentLocked = useSettingLocked('externalContentPolicy');
const showExternalContent = externalContentAllowed && !externalContentHidden;
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const setEmailAlwaysLightMode = useSettingsStore((s) => s.setEmailAlwaysLightMode);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
@@ -134,20 +141,23 @@ export function ContentSendersSettings() {
title={t('settings.tabs.content_senders', 'Content & Senders')}
description={t('settings.email_behavior.content_senders_description_mobile', 'Control how external resources and tracking pixels are handled.')}
>
<SettingItem
label={t('settings.email_behavior.external_content.label', 'External Content')}
description={t('settings.email_behavior.external_content.description', 'How to handle images and external content')}
>
<RadioGroup
value={externalContentPolicy}
onChange={(v) => setExternalContentPolicy(v as ExternalContentPolicy)}
options={[
{ value: 'ask', label: t('settings.email_behavior.external_content.ask_mobile', 'Ask') },
{ value: 'block', label: t('settings.email_behavior.external_content.block_mobile', 'Block') },
{ value: 'allow', label: t('settings.email_behavior.external_content.allow_mobile', 'Allow') },
]}
/>
</SettingItem>
{showExternalContent && (
<SettingItem
label={t('settings.email_behavior.external_content.label', 'External Content')}
description={t('settings.email_behavior.external_content.description', 'How to handle images and external content')}
locked={externalContentLocked}
>
<RadioGroup
value={externalContentPolicy}
onChange={(v) => setExternalContentPolicy(v as ExternalContentPolicy)}
options={[
{ value: 'ask', label: t('settings.email_behavior.external_content.ask_mobile', 'Ask') },
{ value: 'block', label: t('settings.email_behavior.external_content.block_mobile', 'Block') },
{ value: 'allow', label: t('settings.email_behavior.external_content.allow_mobile', 'Allow') },
]}
/>
</SettingItem>
)}
<SettingItem
label={t('settings.email_behavior.always_light_mode.label', 'Always Show Emails in Light Mode')}
+12 -8
View File
@@ -7,7 +7,6 @@ import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
REPLY_QUICK_ACTIONS,
ALL_QUICK_ACTIONS,
type SwipeAction,
@@ -16,7 +15,8 @@ import {
} from '../../stores/settings-store';
import { useEmailStore } from '../../stores/email-store';
import { useKeywordsStore } from '../../stores/keywords-store';
import { useSettingLocked } from '../../stores/policy-store';
import { usePolicyFeature, useSettingLocked } from '../../stores/policy-store';
import { bottomQuickActionsFor, swipeActionShown, withoutArchive } from '../../lib/swipe-actions';
import {
ORDER_PRESETS, detectPreset, presetLevels, sanitizeSortLevels,
type OrderPreset, type MessageListOrderScope,
@@ -74,12 +74,16 @@ export function LayoutSettings() {
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const t = useLocaleStore((s) => s.t);
const SWIPE_OPTIONS = React.useMemo(() => swipeOptions(t), [t]);
// With the organisation's Archive action off, Archive is no swipe or quick
// action, and a stored one shows as what it does (webmail
// message-list-settings.tsx).
const archiveEnabled = usePolicyFeature('archiveActionEnabled');
const SWIPE_OPTIONS = React.useMemo(() => withoutArchive(swipeOptions(t), archiveEnabled), [t, archiveEnabled]);
const SWIPE_MODE_OPTIONS = React.useMemo(() => swipeModeOptions(t), [t]);
const QUICK_ACTION_LABELS = React.useMemo(() => quickActionLabels(t), [t]);
const QUICK_ACTION_OPTIONS = React.useMemo(
() => ALL_QUICK_ACTIONS.map((value) => ({ value, label: QUICK_ACTION_LABELS[value] })),
[QUICK_ACTION_LABELS],
() => withoutArchive(ALL_QUICK_ACTIONS.map((value) => ({ value, label: QUICK_ACTION_LABELS[value] })), archiveEnabled),
[QUICK_ACTION_LABELS, archiveEnabled],
);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
@@ -125,7 +129,7 @@ export function LayoutSettings() {
{ value: 'all', label: t('settings.message_list_order.scope_all', 'All folders') },
];
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
const bottomActions = bottomQuickActionsFor(bottomQuickActionsRaw, archiveEnabled);
// Pick an action for a bar slot. If the chosen action already occupies
// another slot, swap the two so the three slots stay unique.
const setQuickActionSlot = (index: number, value: QuickAction) => {
@@ -242,7 +246,7 @@ export function LayoutSettings() {
{t('settings.email_behavior.swipe_right_action.description_mobile', "Action when you drag a row from its left edge towards the right.")}
</Text>
<RadioGroup
value={swipeRightAction}
value={swipeActionShown(swipeRightAction, archiveEnabled)}
onChange={(v) => setSwipeRightAction(v as SwipeAction)}
options={SWIPE_OPTIONS}
/>
@@ -257,7 +261,7 @@ export function LayoutSettings() {
{t('settings.email_behavior.swipe_left_action.description_mobile', "Action when you drag a row from its right edge towards the left.")}
</Text>
<RadioGroup
value={swipeLeftAction}
value={swipeActionShown(swipeLeftAction, archiveEnabled)}
onChange={(v) => setSwipeLeftAction(v as SwipeAction)}
options={SWIPE_OPTIONS}
/>
@@ -19,6 +19,7 @@ import { useAuthStore } from '../../stores/auth-store';
import { useEmailStore } from '../../stores/email-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore } from '../../stores/settings-store';
import { useSettingHidden, useSettingLocked } from '../../stores/policy-store';
import {
DEFAULT_RELAY_BASE_URL,
disablePushForAccount,
@@ -99,6 +100,13 @@ export function NotificationSettings() {
// The Inbox-only filter needs the server's emailPush filter too (webmail #983).
const inboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
// Settings the organisation pins or hides (webmail notification-settings.tsx).
const emailEnabledLocked = useSettingLocked('emailNotificationsEnabled');
const emailEnabledHidden = useSettingHidden('emailNotificationsEnabled');
const inboxOnlyLocked = useSettingLocked('pushNotifyInboxOnly');
const inboxOnlyHidden = useSettingHidden('pushNotifyInboxOnly');
const calEnabledLocked = useSettingLocked('calendarNotificationsEnabled');
const calEnabledHidden = useSettingHidden('calendarNotificationsEnabled');
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
@@ -570,16 +578,20 @@ export function NotificationSettings() {
title={t('settings.notifications.email.title', 'Email Notifications')}
description={t('settings.notifications.email.description', 'Configure notifications for incoming emails')}
>
<SettingItem
label={t('settings.notifications.email.enabled', 'Email notifications')}
description={t('settings.notifications.email.enabled_desc', 'Show notifications when new emails arrive')}
>
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
{inboxOnlyAvailable && (
{!emailEnabledHidden && (
<SettingItem
label={t('settings.notifications.email.enabled', 'Email notifications')}
description={t('settings.notifications.email.enabled_desc', 'Show notifications when new emails arrive')}
locked={emailEnabledLocked}
>
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
)}
{inboxOnlyAvailable && !inboxOnlyHidden && (
<SettingItem
label={t('settings.notifications.email.inbox_only', 'Inbox only')}
description={t('settings.notifications.email.inbox_only_desc', 'Only notify for mail that lands in the Inbox; skip mail a filter files into another folder')}
locked={inboxOnlyLocked}
>
<ToggleSwitch
checked={inboxOnly}
@@ -629,12 +641,15 @@ export function NotificationSettings() {
title={t('settings.notifications.calendar.title', 'Calendar Notifications')}
description={t('settings.notifications.calendar.description', 'Configure notifications for calendar events')}
>
<SettingItem
label={t('settings.notifications.calendar.enabled', 'Event notifications')}
description={t('settings.notifications.calendar.enabled_desc', 'Show alerts for upcoming calendar events')}
>
<ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} />
</SettingItem>
{!calEnabledHidden && (
<SettingItem
label={t('settings.notifications.calendar.enabled', 'Event notifications')}
description={t('settings.notifications.calendar.enabled_desc', 'Show alerts for upcoming calendar events')}
locked={calEnabledLocked}
>
<ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} />
</SettingItem>
)}
<SettingItem
label={t('settings.notifications.calendar.invitation_parsing', 'Parse email invitations')}
description={t(
+131 -94
View File
@@ -18,6 +18,7 @@ import { useEmailStore } from '../../stores/email-store';
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
import { ownMailboxes } from '../../lib/mailbox-tree';
import { useLocaleStore } from '../../stores/locale-store';
import { usePolicyFeature, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
export function ReadingSettings() {
const c = useColors();
@@ -28,6 +29,9 @@ export function ReadingSettings() {
const update = useSettingsStore((s) => s.updateSetting);
const archiveMode = useSettingsStore((s) => s.archiveMode);
// Where archived mail goes means nothing with the organisation's Archive
// action off (webmail message-list-settings.tsx).
const archiveEnabled = usePolicyFeature('archiveActionEnabled');
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
const deleteAction = useSettingsStore((s) => s.deleteAction);
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
@@ -47,6 +51,22 @@ export function ReadingSettings() {
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const readReceiptResponse = useSettingsStore((s) => s.readReceiptResponse);
// Settings the organisation pins or hides (webmail reading-settings.tsx,
// message-list-settings.tsx, layout-settings.tsx).
const markAsReadLocked = useSettingLocked('markAsReadDelay');
const markAsReadHidden = useSettingHidden('markAsReadDelay');
const deleteActionLocked = useSettingLocked('deleteAction');
const deleteActionHidden = useSettingHidden('deleteAction');
const showPreviewLocked = useSettingLocked('showPreview');
const showPreviewHidden = useSettingHidden('showPreview');
const includeGroupLocked = useSettingLocked('includeGroupInUnified');
const includeGroupHidden = useSettingHidden('includeGroupInUnified');
const plainTextModeLocked = useSettingLocked('plainTextMode');
const autoSelectReplyIdentityLocked = useSettingLocked('autoSelectReplyIdentity');
const messageSpacingLocked = useSettingLocked('messageSpacing');
const messageSpacingHidden = useSettingHidden('messageSpacing');
const emailsPerPageLocked = useSettingLocked('emailsPerPage');
const emailsPerPageHidden = useSettingHidden('emailsPerPage');
const [newKeyword, setNewKeyword] = useState('');
const [reorganizing, setReorganizing] = useState(false);
@@ -114,73 +134,81 @@ export function ReadingSettings() {
return (
<SettingsSection title={t('settings.email_behavior.title', "Email Behavior")} description={t('settings.email_behavior.description', "Configure how emails are handled")}>
<SettingItem label={t('settings.email_behavior.mark_read.label', "Mark as Read")} description={t('settings.email_behavior.mark_read.description', "When to mark emails as read when opened")}>
<Select
value={String(markAsReadDelay)}
onChange={(v) => update('markAsReadDelay', Number(v))}
options={[
{ value: '0', label: t('settings.email_behavior.mark_read.instant', "Instantly") },
{ value: '3000', label: t('settings.email_behavior.mark_read.delay_3s', "After 3 seconds") },
{ value: '5000', label: t('settings.email_behavior.mark_read.delay_5s', "After 5 seconds") },
{ value: '-1', label: t('settings.email_behavior.mark_read.never', "Never") },
]}
/>
</SettingItem>
{!markAsReadHidden && (
<SettingItem label={t('settings.email_behavior.mark_read.label', "Mark as Read")} description={t('settings.email_behavior.mark_read.description', "When to mark emails as read when opened")} locked={markAsReadLocked}>
<Select
value={String(markAsReadDelay)}
onChange={(v) => update('markAsReadDelay', Number(v))}
options={[
{ value: '0', label: t('settings.email_behavior.mark_read.instant', "Instantly") },
{ value: '3000', label: t('settings.email_behavior.mark_read.delay_3s', "After 3 seconds") },
{ value: '5000', label: t('settings.email_behavior.mark_read.delay_5s', "After 5 seconds") },
{ value: '-1', label: t('settings.email_behavior.mark_read.never', "Never") },
]}
/>
</SettingItem>
)}
<View style={styles.group}>
<SettingItem label={t('settings.email_behavior.delete_action.label', "Delete Action")} description={t('settings.email_behavior.delete_action.description', "What happens when you delete an email")} noBorder />
<Select
value={deleteAction}
onChange={(v) => update('deleteAction', v as DeleteAction)}
options={[
{ value: 'trash', label: t('settings.email_behavior.delete_action.trash', "Move to Trash") },
{ value: 'trash-and-read', label: t('settings.email_behavior.delete_action.trash_and_read', "Move to Trash and mark as read") },
{ value: 'permanent', label: t('settings.email_behavior.delete_action.permanent', "Delete Permanently") },
]}
/>
{deleteAction === 'permanent' && (
<View style={styles.warning}>
<AlertTriangle size={14} color={c.error} />
<Text style={styles.warningText}>{t('settings.email_behavior.delete_action.warning', "Emails will be permanently deleted and cannot be recovered. This action is irreversible.")}</Text>
{!deleteActionHidden && (
<View style={styles.group}>
<SettingItem label={t('settings.email_behavior.delete_action.label', "Delete Action")} description={t('settings.email_behavior.delete_action.description', "What happens when you delete an email")} noBorder locked={deleteActionLocked} />
<View pointerEvents={deleteActionLocked ? 'none' : 'auto'} style={deleteActionLocked && styles.lockedControl}>
<Select
value={deleteAction}
onChange={(v) => update('deleteAction', v as DeleteAction)}
options={[
{ value: 'trash', label: t('settings.email_behavior.delete_action.trash', "Move to Trash") },
{ value: 'trash-and-read', label: t('settings.email_behavior.delete_action.trash_and_read', "Move to Trash and mark as read") },
{ value: 'permanent', label: t('settings.email_behavior.delete_action.permanent', "Delete Permanently") },
]}
/>
</View>
)}
<View style={styles.divider} />
</View>
{deleteAction === 'permanent' && (
<View style={styles.warning}>
<AlertTriangle size={14} color={c.error} />
<Text style={styles.warningText}>{t('settings.email_behavior.delete_action.warning', "Emails will be permanently deleted and cannot be recovered. This action is irreversible.")}</Text>
</View>
)}
<View style={styles.divider} />
</View>
)}
<View style={styles.group}>
<SettingItem label={t('settings.email_behavior.archive_mode.label', "Archive in")} description={t('settings.email_behavior.archive_mode.description', "How to organize emails when archiving")} noBorder />
<Select
value={archiveMode}
onChange={(v) => update('archiveMode', v as ArchiveMode)}
options={[
{ value: 'single', label: t('settings.email_behavior.archive_mode.single', "A single folder") },
{ value: 'year', label: t('settings.email_behavior.archive_mode.year', "A folder per year") },
{ value: 'month', label: t('settings.email_behavior.archive_mode.month', "A folder per month") },
]}
/>
{archiveMode !== 'single' && (
<>
<Pressable
style={[styles.inlineBtn, reorganizing && styles.inlineBtnDisabled]}
onPress={reorganizing ? undefined : handleReorganizeArchive}
disabled={reorganizing}
>
{reorganizing ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<FolderSync size={14} color={c.text} />
{archiveEnabled && (
<View style={styles.group}>
<SettingItem label={t('settings.email_behavior.archive_mode.label', "Archive in")} description={t('settings.email_behavior.archive_mode.description', "How to organize emails when archiving")} noBorder />
<Select
value={archiveMode}
onChange={(v) => update('archiveMode', v as ArchiveMode)}
options={[
{ value: 'single', label: t('settings.email_behavior.archive_mode.single', "A single folder") },
{ value: 'year', label: t('settings.email_behavior.archive_mode.year', "A folder per year") },
{ value: 'month', label: t('settings.email_behavior.archive_mode.month', "A folder per month") },
]}
/>
{archiveMode !== 'single' && (
<>
<Pressable
style={[styles.inlineBtn, reorganizing && styles.inlineBtnDisabled]}
onPress={reorganizing ? undefined : handleReorganizeArchive}
disabled={reorganizing}
>
{reorganizing ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<FolderSync size={14} color={c.text} />
)}
<Text style={styles.inlineBtnText}>
{reorganizing ? t('settings.email_behavior.archive_mode.reorganizing', "Reorganizing…") : t('settings.email_behavior.archive_mode.reorganize', "Reorganize existing archive")}
</Text>
</Pressable>
{reorganizeResult && (
<Text style={styles.reorganizeResult}>{reorganizeResult}</Text>
)}
<Text style={styles.inlineBtnText}>
{reorganizing ? t('settings.email_behavior.archive_mode.reorganizing', "Reorganizing…") : t('settings.email_behavior.archive_mode.reorganize', "Reorganize existing archive")}
</Text>
</Pressable>
{reorganizeResult && (
<Text style={styles.reorganizeResult}>{reorganizeResult}</Text>
)}
</>
)}
<View style={styles.divider} />
</View>
</>
)}
<View style={styles.divider} />
</View>
)}
<SettingItem label={t('settings.email_behavior.permanently_delete_junk.label', "Permanently Delete Junk")} description={t('settings.email_behavior.permanently_delete_junk.description', "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash")}>
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
@@ -190,9 +218,11 @@ export function ReadingSettings() {
<ToggleSwitch checked={clearSearchOnFolderChange} onChange={(v) => update('clearSearchOnFolderChange', v)} />
</SettingItem>
<SettingItem label={t('settings.email_behavior.show_preview.label', "Show Preview Text")} description={t('settings.email_behavior.show_preview.description', "Display email preview in the list")}>
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
</SettingItem>
{!showPreviewHidden && (
<SettingItem label={t('settings.email_behavior.show_preview.label', "Show Preview Text")} description={t('settings.email_behavior.show_preview.description', "Display email preview in the list")} locked={showPreviewLocked}>
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
</SettingItem>
)}
<SettingItem label={t('settings.email_behavior.show_verification_codes.label', "Show Verification Codes")} description={t('settings.email_behavior.show_verification_codes.description', "Offer the code from sign-in and confirmation emails for one-click copying, in the list for a day and in the message")}>
<ToggleSwitch checked={showVerificationCodes} onChange={(v) => update('showVerificationCodes', v)} />
@@ -202,15 +232,17 @@ export function ReadingSettings() {
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
</SettingItem>
<SettingItem label={t('settings.appearance.unified_mailbox.include_group.label', "Include group inboxes")} description={t('settings.appearance.unified_mailbox.include_group.description', "Also merge shared/group inboxes into the unified view.")}>
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} />
</SettingItem>
{!includeGroupHidden && (
<SettingItem label={t('settings.appearance.unified_mailbox.include_group.label', "Include group inboxes")} description={t('settings.appearance.unified_mailbox.include_group.description', "Also merge shared/group inboxes into the unified view.")} locked={includeGroupLocked}>
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} />
</SettingItem>
)}
<SettingItem label={t('settings.email_behavior.plain_text_mode.label', "Plain Text Only")} description={t('settings.email_behavior.plain_text_mode.description_mobile', "Compose and read in plain text only.")}>
<SettingItem label={t('settings.email_behavior.plain_text_mode.label', "Plain Text Only")} description={t('settings.email_behavior.plain_text_mode.description_mobile', "Compose and read in plain text only.")} locked={plainTextModeLocked}>
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
</SettingItem>
<SettingItem label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")} description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "When replying, send from the address the message was originally sent to.")}>
<SettingItem label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")} description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "When replying, send from the address the message was originally sent to.")} locked={autoSelectReplyIdentityLocked}>
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={(v) => update('autoSelectReplyIdentity', v)} />
</SettingItem>
@@ -314,17 +346,19 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label={t('settings.email_behavior.message_spacing.label', "Message Spacing")} description={t('settings.email_behavior.message_spacing.description', "Padding around the message body in the reader")}>
<Select
value={messageSpacing}
onChange={(v) => update('messageSpacing', v as MessageSpacing)}
options={[
{ value: 'auto', label: t('settings.email_behavior.message_spacing.auto', "Auto (recommended)") },
{ value: 'always', label: t('settings.email_behavior.message_spacing.always', "Always add spacing") },
{ value: 'edge', label: t('settings.email_behavior.message_spacing.edge', "Edge to edge") },
]}
/>
</SettingItem>
{!messageSpacingHidden && (
<SettingItem label={t('settings.email_behavior.message_spacing.label', "Message Spacing")} description={t('settings.email_behavior.message_spacing.description', "Padding around the message body in the reader")} locked={messageSpacingLocked}>
<Select
value={messageSpacing}
onChange={(v) => update('messageSpacing', v as MessageSpacing)}
options={[
{ value: 'auto', label: t('settings.email_behavior.message_spacing.auto', "Auto (recommended)") },
{ value: 'always', label: t('settings.email_behavior.message_spacing.always', "Always add spacing") },
{ value: 'edge', label: t('settings.email_behavior.message_spacing.edge', "Edge to edge") },
]}
/>
</SettingItem>
)}
<SettingItem label={t('settings.email_behavior.read_receipt_response.label', "Respond to read-receipt requests")} description={t('settings.email_behavior.read_receipt_response.description', "What to do when an incoming message asks for a read receipt.")}>
<Select
@@ -338,18 +372,20 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label={t('settings.email_behavior.emails_per_page.label', "Emails Per Page")} description={t('settings.email_behavior.emails_per_page.description', "Number of emails to load at once")}>
<Select
value={String(emailsPerPage)}
onChange={(v) => update('emailsPerPage', Number(v))}
options={[
{ value: '10', label: '10' },
{ value: '25', label: '25' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
]}
/>
</SettingItem>
{!emailsPerPageHidden && (
<SettingItem label={t('settings.email_behavior.emails_per_page.label', "Emails Per Page")} description={t('settings.email_behavior.emails_per_page.description', "Number of emails to load at once")} locked={emailsPerPageLocked}>
<Select
value={String(emailsPerPage)}
onChange={(v) => update('emailsPerPage', Number(v))}
options={[
{ value: '10', label: '10' },
{ value: '25', label: '25' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
]}
/>
</SettingItem>
)}
</SettingsSection>
);
@@ -358,6 +394,7 @@ export function ReadingSettings() {
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
group: {},
lockedControl: { opacity: 0.6 },
warning: {
flexDirection: 'row',
gap: spacing.sm,
+19 -13
View File
@@ -10,6 +10,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { usePolicyFeature } from '../../stores/policy-store';
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { useOutboxStore } from '../../stores/outbox-store';
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
@@ -29,6 +30,9 @@ export function StorageSettings({ onOpenTroubleshooting }: StorageSettingsProps)
const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
const exportSettings = useSettingsStore((s) => s.exportSettings);
const importSettings = useSettingsStore((s) => s.importSettings);
// The organisation can turn settings export and import off (webmail
// about-data-settings.tsx).
const exportAllowed = usePolicyFeature('settingsExportEnabled');
const [confirmReset, setConfirmReset] = useState(false);
@@ -229,19 +233,21 @@ export function StorageSettings({ onOpenTroubleshooting }: StorageSettingsProps)
title={t('settings.backup.title', 'Settings backup')}
description={t('settings.backup.description', 'Move your settings to another phone or the webmail.')}
>
<SettingItem
label={t('settings.backup.export_import', 'Export or import')}
description={t('settings.backup.export_import_desc', 'A JSON file, same format as the webmail')}
>
<View style={styles.buttonPair}>
<Button variant="outline" size="sm" onPress={() => void handleExport()}>
{t('settings.advanced.export_settings.button', 'Export')}
</Button>
<Button variant="outline" size="sm" onPress={() => void handleImport()}>
{t('settings.advanced.import_settings.button', 'Import')}
</Button>
</View>
</SettingItem>
{exportAllowed && (
<SettingItem
label={t('settings.backup.export_import', 'Export or import')}
description={t('settings.backup.export_import_desc', 'A JSON file, same format as the webmail')}
>
<View style={styles.buttonPair}>
<Button variant="outline" size="sm" onPress={() => void handleExport()}>
{t('settings.advanced.export_settings.button', 'Export')}
</Button>
<Button variant="outline" size="sm" onPress={() => void handleImport()}>
{t('settings.advanced.import_settings.button', 'Import')}
</Button>
</View>
</SettingItem>
)}
<SettingItem
label={t('settings.advanced.reset_settings.label', 'Reset Settings')}
description={t('settings.backup.reset_desc', 'Back to the defaults; mail stays')}
+27 -4
View File
@@ -7,6 +7,8 @@ import { resolvePalette, useColors, useResolvedTheme } from '../../theme/colors'
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { usePolicyStore, useSettingLocked } from '../../stores/policy-store';
import { visibleThemeCards } from './theme-cards';
interface ThemeCard {
id: string | null;
@@ -28,19 +30,23 @@ export function ThemesSettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const active = useSettingsStore((s) => s.activeThemeId);
const update = useSettingsStore((s) => s.updateSetting);
// The organisation can turn built-in themes off, and pin one (or the
// default, with themes turned off) for everyone (webmail themes-settings.tsx).
const policy = usePolicyStore((s) => s.effective);
const themeLocked = useSettingLocked('activeThemeId');
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const cards: ThemeCard[] = [
const cards = React.useMemo<ThemeCard[]>(() => visibleThemeCards([
{
id: null,
name: t('settings.themes.default_name', 'Bulwark'),
description: t('settings.themes.default_description', 'The default light and dark palettes.'),
},
...BUILTIN_THEMES.map((theme) => ({ id: theme.id, name: theme.name, description: theme.description })),
];
], policy), [t, policy]);
return (
<SettingsSection
@@ -50,6 +56,11 @@ export function ThemesSettings() {
'Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.',
)}
>
{themeLocked && (
<View style={styles.lockedNote}>
<Text style={styles.lockedNoteText}>{t('settings.themes.locked', 'Theme selection is locked by an administrator.')}</Text>
</View>
)}
<View style={styles.grid}>
{cards.map((card) => {
const isActive = (active ?? null) === card.id;
@@ -58,10 +69,11 @@ export function ThemesSettings() {
<Pressable
key={card.id ?? 'default'}
onPress={() => update('activeThemeId', card.id)}
disabled={themeLocked}
accessibilityRole="radio"
accessibilityState={{ selected: isActive, checked: isActive }}
accessibilityState={{ selected: isActive, checked: isActive, disabled: themeLocked }}
accessibilityLabel={card.name}
style={[styles.card, isActive && styles.cardActive]}
style={[styles.card, isActive && styles.cardActive, themeLocked && !isActive && styles.cardDisabled]}
>
<View style={[styles.preview, { backgroundColor: preview.background, borderColor: preview.border }]}>
<View style={[styles.previewRail, { backgroundColor: preview.surface, borderRightColor: preview.border }]} />
@@ -112,6 +124,17 @@ function makeStyles(c: ThemePalette) {
borderColor: c.primary,
backgroundColor: c.primaryBg,
},
cardDisabled: { opacity: 0.6 },
lockedNote: {
marginBottom: spacing.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.warning,
backgroundColor: c.warningBg,
},
lockedNoteText: { ...typography.caption, color: c.text },
preview: {
width: '100%',
aspectRatio: 16 / 10,
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Where the organisation policy reaches the settings pages. No React Native
// renderer in the test env, so this checks the wiring (as
// src/components/__tests__/org-policy-wiring.test.ts does); what the policy
// does to the settings is tested in org-policy.test.ts and policy-store.test.ts.
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', '..', '..', rel), 'utf8');
const settings = (name: string) => read(`src/components/settings/${name}.tsx`);
describe('organisation policy on the settings pages', () => {
it('shows the colour mode as set by the organisation when its theme policy locks it', () => {
const page = settings('AppearanceSettings');
expect(page).toContain("const themeLocked = useSettingLocked('theme');");
expect(page).toMatch(/label=\{t\('settings\.appearance\.theme\.label', "Theme"\)\}[^>]*locked=\{themeLocked\}>/);
});
it('hides the themes the policy turns off and keeps the grid still while the theme is locked', () => {
const page = settings('ThemesSettings');
expect(page).toContain('const policy = usePolicyStore((s) => s.effective);');
expect(page).toMatch(/visibleThemeCards\(\[[\s\S]*\], policy\), \[t, policy\]\)/);
expect(page).toContain('{cards.map((card) => {');
expect(page).toContain("const themeLocked = useSettingLocked('activeThemeId');");
expect(page).toContain('disabled={themeLocked}');
expect(page).toContain("{themeLocked && (");
});
it('hides the tasks option when the organisation turns tasks off, and marks it locked', () => {
const page = settings('CalendarSettings');
expect(page).toContain("const tasksAllowed = usePolicyFeature('calendarTasksEnabled');");
expect(page).toContain("const tasksLocked = useSettingLocked('enableCalendarTasks');");
expect(page).toMatch(/\{tasksAllowed && \(\s*<SettingItem\s+label=\{t\('calendar\.settings\.enable_tasks'[\s\S]*?locked=\{tasksLocked\}/);
expect(page).toContain('{tasksAllowed && tasksEnabled && (');
});
it('hides the external content option when the organisation turns it off or hides it', () => {
const page = settings('ContentSendersSettings');
expect(page).toContain("const externalContentAllowed = usePolicyFeature('externalContentEnabled');");
expect(page).toContain("const externalContentHidden = useSettingHidden('externalContentPolicy');");
expect(page).toContain('const showExternalContent = externalContentAllowed && !externalContentHidden;');
expect(page).toMatch(/\{showExternalContent && \(\s*<SettingItem\s+label=\{t\('settings\.email_behavior\.external_content\.label'[\s\S]*?locked=\{externalContentLocked\}/);
expect(page).toContain("const externalContentLocked = useSettingLocked('externalContentPolicy');");
});
it('offers settings export and import only while the organisation allows them', () => {
const page = settings('StorageSettings');
expect(page).toContain("const exportAllowed = usePolicyFeature('settingsExportEnabled');");
expect(page).toMatch(/\{exportAllowed && \(\s*<SettingItem\s+label=\{t\('settings\.backup\.export_import'[\s\S]*?handleExport[\s\S]*?handleImport[\s\S]*?<\/SettingItem>\s*\)\}/);
});
it('takes Archive out of the swipe and quick actions when the organisation turns it off', () => {
const layout = settings('LayoutSettings');
expect(layout).toContain("const archiveEnabled = usePolicyFeature('archiveActionEnabled');");
expect(layout).toContain('withoutArchive(swipeOptions(t), archiveEnabled)');
expect(layout).toContain('value={swipeActionShown(swipeRightAction, archiveEnabled)}');
expect(layout).toContain('value={swipeActionShown(swipeLeftAction, archiveEnabled)}');
expect(layout).toMatch(/withoutArchive\(ALL_QUICK_ACTIONS\.map\([\s\S]*?\), archiveEnabled\)/);
expect(layout).toContain('const bottomActions = bottomQuickActionsFor(bottomQuickActionsRaw, archiveEnabled);');
const reading = settings('ReadingSettings');
expect(reading).toContain("const archiveEnabled = usePolicyFeature('archiveActionEnabled');");
expect(reading).toMatch(/\{archiveEnabled && \(\s*<View style=\{styles\.group\}>\s*<SettingItem label=\{t\('settings\.email_behavior\.archive_mode\.label'/);
});
// The webmail's lockable settings (lib/admin/restriction-keys.ts, page
// `policy`) the app has a row for, and whether the webmail also hides the
// row when the restriction says so.
const LOCKABLE_ROWS: ReadonlyArray<[page: string, key: string, hides: boolean]> = [
['AppearanceSettings', 'density', true],
['AccessibilitySettings', 'toastDuration', false],
['AccessibilitySettings', 'sendConfirmation', true],
['ReadingSettings', 'markAsReadDelay', true],
['ReadingSettings', 'deleteAction', true],
['ReadingSettings', 'showPreview', true],
['ReadingSettings', 'includeGroupInUnified', true],
['ReadingSettings', 'plainTextMode', false],
['ReadingSettings', 'autoSelectReplyIdentity', false],
['ReadingSettings', 'messageSpacing', true],
['ReadingSettings', 'emailsPerPage', true],
['ComposingSettings', 'autoSelectReplyIdentity', false],
['ComposingSettings', 'replyIdentityMatch', false],
['ComposingSettings', 'plainTextMode', false],
['NotificationSettings', 'emailNotificationsEnabled', true],
['NotificationSettings', 'pushNotifyInboxOnly', true],
['NotificationSettings', 'calendarNotificationsEnabled', true],
];
it.each(LOCKABLE_ROWS)('%s marks %s as set by the organisation when locked', (page, key, hides) => {
const source = settings(page);
const locked = new RegExp(`const (\\w+) = useSettingLocked\\('${key}'\\);`).exec(source);
expect(locked, `useSettingLocked('${key}')`).not.toBeNull();
// On the row, or on the control of a row whose control sits outside it.
expect(source).toMatch(new RegExp(`locked=\\{${locked![1]}\\}|pointerEvents=\\{${locked![1]} \\? 'none' : 'auto'\\}`));
if (hides) {
const hidden = new RegExp(`const (\\w+) = useSettingHidden\\('${key}'\\);`).exec(source);
expect(hidden, `useSettingHidden('${key}')`).not.toBeNull();
expect(source).toMatch(new RegExp(`\\{!${hidden![1]} && \\(|&& !${hidden![1]} && \\(`));
}
});
it('keeps notification durations under the organisation minimum out of the choices', () => {
const page = settings('AccessibilitySettings');
expect(page).toContain("policyAllowsValue('toastDuration', value, policyRules)");
expect(page).toContain('options={toastChoices.map(');
});
it('keeps the controls of locked rows that sit outside their row still', () => {
expect(settings('AppearanceSettings')).toMatch(/<View pointerEvents=\{densityLocked \? 'none' : 'auto'\}[^>]*>\s*<RadioGroup\s+value=\{density\}/);
expect(settings('ReadingSettings')).toMatch(/<View pointerEvents=\{deleteActionLocked \? 'none' : 'auto'\}[^>]*>\s*<Select\s+value=\{deleteAction\}/);
expect(settings('ComposingSettings')).toMatch(/<View pointerEvents=\{replyIdentityMatchLocked \? 'none' : 'auto'\}[^>]*>\s*\{segmented\(REPLY_IDENTITY_MATCHES/);
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { policyHiddenPages } from '../policy-pages';
import { sanitizePolicy } from '../../../lib/org-policy';
const screen = fs.readFileSync(path.resolve(__dirname, '..', '..', '..', 'screens', 'SettingsScreen.tsx'), 'utf8');
describe('policyHiddenPages', () => {
it('leaves every page without a policy or with the default gates', () => {
expect(policyHiddenPages(null)).toEqual([]);
expect(policyHiddenPages(sanitizePolicy({}))).toEqual([]);
});
it('takes away the pages of the gates the policy turns off', () => {
const policy = sanitizePolicy({
features: {
templatesEnabled: false,
customKeywordsEnabled: false,
sidebarAppsEnabled: false,
themesEnabled: false,
calendarEnabled: false,
contactsEnabled: false,
filesEnabled: false,
},
});
expect(policyHiddenPages(policy).sort()).toEqual(
['calendar', 'contacts', 'files', 'keywords', 'sidebar_apps', 'templates', 'themes'],
);
});
it('takes away Delegates and Recover deleted items when the policy hides them', () => {
const policy = sanitizePolicy({
restrictions: { delegation: { hidden: true }, deletedItemsRecovery: { hidden: true } },
});
expect(policyHiddenPages(policy).sort()).toEqual(['recovery', 'sharing']);
const locked = sanitizePolicy({ restrictions: { delegation: { locked: true } } });
expect(policyHiddenPages(locked)).toEqual([]);
});
it('names pages the Settings list has', () => {
const policy = sanitizePolicy({
features: Object.fromEntries(['templatesEnabled', 'customKeywordsEnabled', 'sidebarAppsEnabled', 'themesEnabled', 'calendarEnabled', 'contactsEnabled', 'filesEnabled'].map((g) => [g, false])),
restrictions: { delegation: { hidden: true }, deletedItemsRecovery: { hidden: true } },
});
for (const page of policyHiddenPages(policy)) expect(screen).toContain(`{ id: '${page}',`);
});
});
describe('Settings list', () => {
it('leaves out the pages the active policy takes away', () => {
expect(screen).toContain('const policy = usePolicyStore((s) => s.effective);');
expect(screen).toContain('for (const page of policyHiddenPages(policy)) set.add(page as Tab);');
expect(screen).toContain('}, [recoveryAvailable, delegationAvailable, policy]);');
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { visibleThemeCards } from '../theme-cards';
import { sanitizePolicy } from '../../../lib/org-policy';
const CARDS = [
{ id: null, name: 'Bulwark' },
{ id: 'nord', name: 'Nord' },
{ id: 'solarized', name: 'Solarized' },
];
describe('visibleThemeCards', () => {
it('offers every card without a policy', () => {
expect(visibleThemeCards(CARDS, null).map((c) => c.id)).toEqual([null, 'nord', 'solarized']);
});
it('leaves out the built-in themes the policy turns off', () => {
const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord'] } });
expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null, 'solarized']);
});
it('keeps the default card whatever the policy lists', () => {
const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord', 'solarized', 'null', ''] } });
expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null]);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { isFeatureEnabled, type FeatureGate, type OrgPolicy } from '../../lib/org-policy';
/**
* Settings pages a feature gate takes away, as the webmail's settings-app.tsx
* leaves their tabs out: Templates (templatesEnabled, in Composing there),
* Keywords (customKeywordsEnabled: Organize turns into Folders), Sidebar apps
* (sidebarAppsEnabled: the app has no plugins, which keep Add-ons there),
* Themes (themesEnabled, appearance-settings.tsx), and Calendar, Contacts and
* Files with their apps.
*/
const GATED_PAGES: ReadonlyArray<[FeatureGate, string]> = [
['templatesEnabled', 'templates'],
['customKeywordsEnabled', 'keywords'],
['sidebarAppsEnabled', 'sidebar_apps'],
['themesEnabled', 'themes'],
['calendarEnabled', 'calendar'],
['contactsEnabled', 'contacts'],
['filesEnabled', 'files'],
];
/**
* Pages a hidden restriction takes away: Delegates (`delegation`, webmail
* settings-app supportsSharing) and Recover deleted items
* (`deletedItemsRecovery`, webmail hooks/use-deleted-items-recovery.ts).
*/
const HIDDEN_PAGES: ReadonlyArray<[string, string]> = [
['delegation', 'sharing'],
['deletedItemsRecovery', 'recovery'],
];
/** The settings pages the active account's policy leaves out, by page id. */
export function policyHiddenPages(policy: OrgPolicy | null | undefined): string[] {
if (!policy) return [];
const out: string[] = [];
for (const [gate, page] of GATED_PAGES) {
if (!isFeatureEnabled(policy, gate)) out.push(page);
}
for (const [key, page] of HIDDEN_PAGES) {
if (policy.restrictions[key]?.hidden === true) out.push(page);
}
return out;
}
+16
View File
@@ -0,0 +1,16 @@
import type { OrgPolicy } from '../../lib/org-policy';
/**
* The theme cards the Themes page offers: every card but the built-in themes
* the organisation's policy turns off (webmail themes-settings.tsx filters
* them with isThemeDisabled). The default card (id null) always stays, so
* there is something to pick.
*/
export function visibleThemeCards<T extends { id: string | null }>(
cards: readonly T[],
policy: OrgPolicy | null | undefined,
): T[] {
const disabled = policy?.themePolicy.disabledBuiltinThemes ?? [];
if (disabled.length === 0) return [...cards];
return cards.filter((card) => card.id === null || !disabled.includes(card.id));
}
+115
View File
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import {
bimiLogoUrl,
brandIndicatorDomain,
isNoLogoImage,
markBrandLogoFailed,
resetBrandLogosForTests,
showBrandLogo,
} from '../avatar-bimi';
import { avatarContent } from '../avatar-utils';
import { LIST_AUTH_RESULTS_PROPERTY } from '../email-headers';
import type { Email } from '../../api/types';
// BIMI logos as the webmail shows them (components/ui/avatar.tsx,
// lib/email-headers.ts brandIndicatorDomain, app/api/bimi).
const PASS = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example';
const FAIL = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example';
const OTHER_DOMAIN = 'mx.example.org; spf=pass smtp.mailfrom=other.example; dkim=pass header.d=other.example; dmarc=pass header.from=other.example';
const NO_DMARC = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example';
const SPOOFED_PASS = 'mx.example.org; spf=fail smtp.mailfrom=bank.example; dkim=none; dmarc=pass header.from=bank.example';
function row(auth?: string[], from = 'billing@bank.example'): Email {
return {
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z',
hasAttachment: false, from: [{ name: 'Bank', email: from }],
...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}),
} as Email;
}
beforeEach(() => resetBrandLogosForTests());
describe('brandIndicatorDomain', () => {
it('names the From domain of a message that passed DMARC for it, from the list property or the headers', () => {
expect(brandIndicatorDomain(row([PASS]))).toBe('bank.example');
const full = { ...row(), headers: [{ name: 'Authentication-Results', value: ` ${PASS}` }] };
expect(brandIndicatorDomain(full)).toBe('bank.example');
expect(brandIndicatorDomain(row([PASS], 'Billing@BANK.example'))).toBe('bank.example');
});
it('names none for a failed, missing or foreign DMARC result, or a spoofed message', () => {
expect(brandIndicatorDomain(row([FAIL]))).toBeUndefined();
expect(brandIndicatorDomain(row([NO_DMARC]))).toBeUndefined();
expect(brandIndicatorDomain(row([OTHER_DOMAIN]))).toBeUndefined();
expect(brandIndicatorDomain(row([SPOOFED_PASS]))).toBeUndefined();
expect(brandIndicatorDomain(row())).toBeUndefined();
expect(brandIndicatorDomain(undefined)).toBeUndefined();
});
});
describe('bimiLogoUrl', () => {
it('asks the account webmail for the domain', () => {
expect(bimiLogoUrl('https://mail.example.org/', 'bank.example')).toBe('https://mail.example.org/api/bimi?domain=bank.example');
expect(bimiLogoUrl('https://mail.example.org/webmail', 'a&b.example')).toBe('https://mail.example.org/webmail/api/bimi?domain=a%26b.example');
});
it('has none without a webmail', () => {
expect(bimiLogoUrl(null, 'bank.example')).toBeNull();
expect(bimiLogoUrl('', 'bank.example')).toBeNull();
expect(bimiLogoUrl('javascript:alert(1)', 'bank.example')).toBeNull();
});
});
describe('showBrandLogo', () => {
const base = { senderFavicons: true, email: 'billing@bank.example', brandDomain: 'bank.example', webmailUrl: 'https://mail.example.org' };
it('shows the logo for the address whose message passed', () => {
expect(showBrandLogo(base)).toBe(true);
});
it('follows the sender favicon switch, which is off by default', () => {
expect(showBrandLogo({ ...base, senderFavicons: false })).toBe(false);
});
it('shows none for another address than the one the check was for', () => {
expect(showBrandLogo({ ...base, email: 'someone@elsewhere.example' })).toBe(false);
expect(showBrandLogo({ ...base, email: 'news@mail.bank.example' })).toBe(false);
});
it('shows none without a webmail or a passing domain', () => {
expect(showBrandLogo({ ...base, webmailUrl: undefined })).toBe(false);
expect(showBrandLogo({ ...base, brandDomain: undefined })).toBe(false);
});
it('stops asking for a logo that failed this session', () => {
markBrandLogoFailed('https://mail.example.org/api/bimi?domain=bank.example');
expect(showBrandLogo(base)).toBe(false);
});
it('reads the 1x1 answer as no logo', () => {
expect(isNoLogoImage({ width: 1, height: 1 })).toBe(true);
expect(isNoLogoImage({ width: 128, height: 128 })).toBe(false);
expect(isNoLogoImage(undefined)).toBe(false);
});
});
describe('avatar order with a BIMI logo', () => {
it('keeps the warning shield and the contact photo ahead of the logo, and the logo ahead of the favicon', () => {
expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showBrand: true, showFavicon: true })).toBe('warning');
expect(avatarContent({ hasPhoto: true, showBrand: true, showFavicon: true })).toBe('photo');
expect(avatarContent({ hasPhoto: false, showBrand: true, showFavicon: true })).toBe('brand');
expect(avatarContent({ hasPhoto: false, showBrand: false, showFavicon: true })).toBe('favicon');
});
it('draws the logo in SenderAvatar under the favicon switch, never for a warned sender', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components', 'SenderAvatar.tsx'), 'utf8');
// senderFavicons there is the setting, off for disabled images and a warned sender.
expect(source).toContain("const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;");
expect(source).toContain('showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl })');
expect(source).toContain('const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null);');
expect(source).toMatch(/if \(content === 'brand' && brandUrl\) \{[\s\S]*?source=\{\{ uri: brandUrl \}\}[\s\S]*?onError=\{handleBrandFailed\}/);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { bottomQuickActionsFor, swipeActionShown, withoutArchive } from '../swipe-actions';
describe('archive action gate', () => {
it('shows a stored Archive swipe as none while archiving is off', () => {
expect(swipeActionShown('archive', false)).toBe('none');
expect(swipeActionShown('archive', true)).toBe('archive');
expect(swipeActionShown('delete', false)).toBe('delete');
});
it('drops Archive from the pickers while archiving is off', () => {
const options = [{ value: 'none' }, { value: 'archive' }, { value: 'delete' }];
expect(withoutArchive(options, false).map((o) => o.value)).toEqual(['none', 'delete']);
expect(withoutArchive(options, true).map((o) => o.value)).toEqual(['none', 'archive', 'delete']);
});
it('fills a stored Archive quick action with a reply action, keeping three', () => {
expect(bottomQuickActionsFor(['archive', 'delete', 'reply'], false)).toEqual(['delete', 'reply', 'replyAll']);
expect(bottomQuickActionsFor(['reply', 'archive', 'replyAll'], false)).toEqual(['reply', 'replyAll', 'forward']);
expect(bottomQuickActionsFor(['archive', 'delete', 'reply'], true)).toEqual(['archive', 'delete', 'reply']);
});
});
+96
View File
@@ -0,0 +1,96 @@
// BIMI sender logos (webmail components/ui/avatar.tsx, lib/email-headers.ts
// brandIndicatorDomain, app/api/bimi/route.ts). The logo comes from the
// webmail the account signed in through: GET <webmail>/api/bimi?domain=<From
// domain> answers it as a PNG, or a 1x1 transparent PNG when the domain has
// none. The webmail caches its answers (6 h for a logo, 1 h for none); a
// domain without a logo is also remembered here for the session.
import type { Email } from '../api/types';
import {
LIST_AUTH_RESULTS_PROPERTY,
headerValues,
isAuthenticationSpoofed,
parseAuthenticationResults,
type AuthenticationResults,
} from './email-headers';
function domainOf(address: string): string | undefined {
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
}
/** The Authentication-Results of a message as the list or the reader holds it. */
function authenticationResultsOf(email: Pick<Email, 'headers' | 'from'>): AuthenticationResults | undefined {
let values: string[];
if (email.headers) {
values = headerValues(email.headers, 'Authentication-Results');
} else {
const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY];
values = Array.isArray(raw) ? raw.filter((value): value is string => typeof value === 'string') : [];
}
return values.length > 0 ? parseAuthenticationResults(values) : undefined;
}
/**
* The From domain of a message that passed DMARC for that domain, whose
* BIMI logo may stand for the sender (webmail brandIndicatorDomain). The
* logo says who the domain is, not that a message came from it, so a message
* without a DMARC pass for its own From domain gets none. Undefined for no
* message (rows that show the recipient).
*/
export function brandIndicatorDomain(email: Pick<Email, 'headers' | 'from'> | undefined): string | undefined {
if (!email) return undefined;
const fromEmail = email.from?.[0]?.email ?? undefined;
const auth = authenticationResultsOf(email);
if (!auth?.dmarc || auth.dmarc.result !== 'pass' || !fromEmail) return undefined;
const domain = domainOf(fromEmail);
if (!domain || isAuthenticationSpoofed(auth)) return undefined;
const checked = auth.dmarc.domain ? domainOf(`@${auth.dmarc.domain}`) : domain;
return checked === domain ? domain : undefined;
}
/** The logo URL on the account's webmail; null without a usable webmail address. */
export function bimiLogoUrl(webmailUrl: string | null | undefined, domain: string): string | null {
const base = webmailUrl?.trim().replace(/\/+$/, '');
if (!base || !/^https?:\/\/[^/]/i.test(base)) return null;
return `${base}/api/bimi?domain=${encodeURIComponent(domain)}`;
}
/**
* Whether the avatar of `email` may show the BIMI logo of `brandDomain`
* (webmail Avatar showBrand): only under the sender favicon switch, only for
* the address shown (a thread row may show another participant than the
* message whose DMARC pass named the domain), and only from a webmail.
*/
export function showBrandLogo(options: {
senderFavicons: boolean;
email: string | null | undefined;
brandDomain: string | null | undefined;
webmailUrl: string | null | undefined;
}): boolean {
const { senderFavicons, email, brandDomain, webmailUrl } = options;
if (!senderFavicons || !brandDomain || !email) return false;
if (domainOf(email) !== brandDomain) return false;
const url = bimiLogoUrl(webmailUrl, brandDomain);
return url !== null && !failedBrandLogos.has(url);
}
// Logos that didn't load or answered "no logo", for the session.
const failedBrandLogos = new Set<string>();
export function markBrandLogoFailed(url: string): void {
failedBrandLogos.add(url);
}
export function hasBrandLogoFailed(url: string): boolean {
return failedBrandLogos.has(url);
}
/** The webmail's "no logo" answer is a 1x1 transparent PNG. */
export function isNoLogoImage(size: { width?: number; height?: number } | null | undefined): boolean {
return !!size && typeof size.width === 'number' && size.width <= 1;
}
export function resetBrandLogosForTests(): void {
failedBrandLogos.clear();
}
+6 -3
View File
@@ -238,21 +238,24 @@ export function getContactPhotoForEmail(
// --- What the avatar shows --------------------------------------------------
export type AvatarContent = 'warning' | 'photo' | 'favicon' | 'initials';
export type AvatarContent = 'warning' | 'photo' | 'brand' | 'favicon' | 'initials';
/**
* What a sender avatar shows, in the webmail `Avatar`'s order. A failed or
* unverified sender check comes first: a warning shield instead of any photo,
* favicon or initials, so a forged From doesn't get the real sender's logo or
* contact photo (webmail 605a3983b, #1439).
* logo, favicon or initials, so a forged From doesn't get the real sender's
* logo or contact photo (webmail 605a3983b, #1439). Then the contact photo,
* the domain's BIMI logo (see avatar-bimi.ts), the favicon.
*/
export function avatarContent(options: {
senderWarning?: string | null;
hasPhoto: boolean;
showBrand?: boolean;
showFavicon: boolean;
}): AvatarContent {
if (options.senderWarning) return 'warning';
if (options.hasPhoto) return 'photo';
if (options.showBrand) return 'brand';
if (options.showFavicon) return 'favicon';
return 'initials';
}
+24
View File
@@ -0,0 +1,24 @@
import { normalizeBottomQuickActions, type QuickAction, type SwipeAction } from '../stores/settings-store';
// The organisation's archiveActionEnabled gate (webmail
// message-list-settings.tsx): with it off, Archive is no swipe action, no
// row action and no quick action, and a stored one does nothing.
/** What a swipe does: a stored Archive swipe does nothing while archiving is off. */
export function swipeActionShown(action: SwipeAction, archiveEnabled: boolean): SwipeAction {
return !archiveEnabled && action === 'archive' ? 'none' : action;
}
/** The actions a picker offers: without Archive while archiving is off. */
export function withoutArchive<T extends { value: string }>(options: readonly T[], archiveEnabled: boolean): T[] {
return archiveEnabled ? [...options] : options.filter((option) => option.value !== 'archive');
}
/**
* The reader's three quick actions: a stored Archive slot gives way to a
* reply action while archiving is off, so the bar keeps three buttons.
*/
export function bottomQuickActionsFor(value: unknown, archiveEnabled: boolean): QuickAction[] {
const actions = normalizeBottomQuickActions(value);
return archiveEnabled ? actions : normalizeBottomQuickActions(actions.filter((action) => action !== 'archive'));
}
+6 -1
View File
@@ -54,6 +54,8 @@ import { useAuthStore } from '../stores/auth-store';
import { useManagedAccountStore } from '../stores/managed-account-store';
import { useAccountStore } from '../stores/account-store';
import { useSettingsStore } from '../stores/settings-store';
import { usePolicyStore } from '../stores/policy-store';
import { policyHiddenPages } from '../components/settings/policy-pages';
import { useUpdatesStore } from '../stores/updates-store';
import { SUPPORTED_LOCALES } from '../i18n';
import { settingsSummary, type SummaryState } from '../lib/settings-summary';
@@ -231,12 +233,15 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
// list, not shown as "Not on this server", where the server lacks them.
const recoveryAvailable = useDeletedItemsRecovery().available;
const delegationAvailable = useDelegationAvailable();
// Pages the organisation's policy takes away (feature gates, hidden pages).
const policy = usePolicyStore((s) => s.effective);
const hiddenTabs = React.useMemo(() => {
const set = new Set<Tab>();
if (!recoveryAvailable) set.add('recovery');
if (!delegationAvailable) set.add('sharing');
for (const page of policyHiddenPages(policy)) set.add(page as Tab);
return set;
}, [recoveryAvailable, delegationAvailable]);
}, [recoveryAvailable, delegationAvailable, policy]);
// A shared/group account picked under "Shared with me" scopes Settings to
// the panes that account can be managed in (webmail: scoped settings).