mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: show a warning shield instead of the sender photo when the sender check fails (bulwarkmail/webmail#1439)
This commit is contained in:
@@ -37,7 +37,9 @@ import {
|
||||
searchEmails,
|
||||
sendEmail,
|
||||
MAILBOX_PROPERTIES,
|
||||
queryEmailPage,
|
||||
} from '../email';
|
||||
import { senderWarningOf } from '../../lib/email-headers';
|
||||
import { uploadBytes } from '../blob';
|
||||
import { getIdentities, updateIdentity } from '../identity';
|
||||
import { isJMAPSetError } from '../jmap-result';
|
||||
@@ -418,4 +420,32 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('mail list (reader pass)', () => {
|
||||
it('lists the Authentication-Results headers so a forged From gets the warning shield (bulwarkmail/webmail#1439)', async () => {
|
||||
const subject = `forged ${mailTag}`;
|
||||
const raw = [
|
||||
'Authentication-Results: mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example',
|
||||
'From: Bank <ceo@bank.example>',
|
||||
`To: ${USER}`,
|
||||
`Subject: ${subject}`,
|
||||
`Date: ${new Date().toUTCString()}`,
|
||||
`Message-ID: <forged-${RUN}@live.test>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'hello',
|
||||
'',
|
||||
].join('\r\n');
|
||||
const { blobId } = await uploadBytes(new TextEncoder().encode(raw), 'message/rfc822');
|
||||
const id = await importEmailBlob(blobId, scratch, {});
|
||||
const page = await queryEmailPage(scratch, { limit: 10 });
|
||||
const row = page.list.find((e) => e.id === id);
|
||||
expect(row).toBeDefined();
|
||||
expect(senderWarningOf(row!)?.status).toBe('failed');
|
||||
// The full fetch reads the same verdict from `headers`.
|
||||
const [full] = await getFullEmails([id]);
|
||||
expect(senderWarningOf(full)?.status).toBe('failed');
|
||||
}, 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-3
@@ -19,17 +19,27 @@ import { hasTruncatedDisplayedBody } from '../lib/email-body';
|
||||
import { withInlineBodyParts } from '../lib/email-inline-parts';
|
||||
import { stalwartStateWentBack } from './stalwart-state';
|
||||
import { isTransientNetworkError } from '../lib/network-error';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||
|
||||
export const EMAIL_LIST_PROPERTIES = [
|
||||
const EMAIL_SUMMARY_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
||||
];
|
||||
|
||||
export const EMAIL_LIST_PROPERTIES = [
|
||||
...EMAIL_SUMMARY_PROPERTIES,
|
||||
// The sender check, so a list row shows a warning shield instead of the
|
||||
// favicon or photo of a forged From (webmail #1439).
|
||||
LIST_AUTH_RESULTS_PROPERTY,
|
||||
];
|
||||
|
||||
// The viewer derives reply threading (In-Reply-To/References), Reply-To
|
||||
// handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests
|
||||
// from these; without `messageId`/`headers` none of that can work.
|
||||
// from these; without `messageId`/`headers` none of that can work. The full
|
||||
// `headers` carry Authentication-Results, so the list's header property is
|
||||
// left out.
|
||||
export const EMAIL_FULL_PROPERTIES = [
|
||||
...EMAIL_LIST_PROPERTIES,
|
||||
...EMAIL_SUMMARY_PROPERTIES,
|
||||
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
|
||||
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
|
||||
'messageId', 'inReplyTo', 'references', 'headers',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { keywordPointer, mailboxPointer } from './patch-pointer';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { freshStoredCredentials } from '../lib/stored-token-refresh';
|
||||
import { textConditions } from '../lib/search-utils';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||
|
||||
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
|
||||
// Unread / Starred). Because the JMAP client is a single-account singleton
|
||||
@@ -21,6 +22,8 @@ import { textConditions } from '../lib/search-utils';
|
||||
const EMAIL_LIST_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
||||
// The sender check, for the warning shield on a forged From (#1439).
|
||||
LIST_AUTH_RESULTS_PROPERTY,
|
||||
];
|
||||
|
||||
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
avatarContent,
|
||||
generateEmailAvatarColor,
|
||||
getContactPhotoForEmail,
|
||||
getEmailInitials,
|
||||
@@ -14,6 +15,8 @@ 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 { useColors } from '../theme/colors';
|
||||
import { ShieldAlert } from './icons';
|
||||
|
||||
interface SenderAvatarProps {
|
||||
name?: string | null;
|
||||
@@ -32,23 +35,32 @@ interface SenderAvatarProps {
|
||||
* the contact has no photo, so no other card's photo is borrowed.
|
||||
*/
|
||||
photo?: ContactPhotoRef | null;
|
||||
/**
|
||||
* The message's sender check failed or is unverified (see
|
||||
* useSenderWarning): a warning shield with this label instead of any photo,
|
||||
* favicon or initials, so a forged From doesn't get the real sender's logo
|
||||
* or photo (webmail #1439).
|
||||
*/
|
||||
senderWarning?: string | null;
|
||||
}
|
||||
|
||||
// Mirrors the webmail `Avatar` priority for the subset available on native:
|
||||
// address-book photo → 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 }: SenderAvatarProps) {
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages;
|
||||
// sender warning → address-book photo → 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) {
|
||||
const c = useColors();
|
||||
const warned = !!senderWarning;
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
|
||||
// Compared shallowly, so only avatars whose photo changed re-render when the
|
||||
// contacts change; the index behind it is built once per contacts array.
|
||||
const indexedPhoto = useContactsStore(
|
||||
useShallow((s) => (photo === undefined ? getContactPhotoForEmail(s.contacts, email) : undefined)),
|
||||
useShallow((s) => (photo === undefined && !warned ? getContactPhotoForEmail(s.contacts, email) : undefined)),
|
||||
);
|
||||
const contactPhoto = photo === undefined ? indexedPhoto : photo;
|
||||
// A blob photo (Stalwart 1.0) has no source while it waits for its
|
||||
// download or after it failed; the favicon or initials show meanwhile.
|
||||
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
|
||||
useContactPhotoSource(disableImages ? null : contactPhoto);
|
||||
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
|
||||
const [imgError, setImgError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -61,8 +73,11 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
|
||||
const faviconDomain = getFaviconDomain(email ?? undefined);
|
||||
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
|
||||
const showPhoto = !disableImages && !!photoSource;
|
||||
const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed;
|
||||
const content = avatarContent({
|
||||
senderWarning,
|
||||
hasPhoto: !disableImages && !!photoSource,
|
||||
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
|
||||
});
|
||||
|
||||
const handleError = React.useCallback(() => {
|
||||
setImgError(true);
|
||||
@@ -75,14 +90,29 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: showPhoto ? 'transparent' : showFavicon ? '#ffffff' : bgColor,
|
||||
backgroundColor: content === 'warning'
|
||||
? c.errorBg
|
||||
: content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor,
|
||||
},
|
||||
style,
|
||||
];
|
||||
|
||||
const fontSize = Math.round(size * 0.4);
|
||||
|
||||
if (showPhoto && photoSource) {
|
||||
if (content === 'warning') {
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
accessible
|
||||
accessibilityLabel={`${primaryName || email || ''} (${senderWarning})`}
|
||||
testID="sender-warning-avatar"
|
||||
>
|
||||
<ShieldAlert size={Math.round(size * 0.5)} color={c.error} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (content === 'photo' && photoSource) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Image
|
||||
@@ -96,7 +126,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
);
|
||||
}
|
||||
|
||||
if (showFavicon && faviconDomain) {
|
||||
if (content === 'favicon' && faviconDomain) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Image
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { accountId: 'acc-1', request: vi.fn(), getMaxObjectsInGet: vi.fn(() => 50) },
|
||||
}));
|
||||
|
||||
import { avatarContent } from '../../lib/avatar-utils';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY, senderWarningOf } from '../../lib/email-headers';
|
||||
import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES } from '../../api/email';
|
||||
import type { Email } from '../../api/types';
|
||||
|
||||
// A forged From kept the real sender's favicon or contact photo: the reader
|
||||
// worked out that the sender check failed, but the avatar beside it still
|
||||
// showed the claimed sender's logo, and list rows had no auth data at all
|
||||
// (webmail 605a3983b, #1439). Now the avatar shows a warning shield.
|
||||
|
||||
const FORGED = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example';
|
||||
const GOOD = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example';
|
||||
|
||||
function row(auth: string[] | undefined): Email {
|
||||
return {
|
||||
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z',
|
||||
hasAttachment: false, from: [{ name: 'Bank', email: 'ceo@bank.example' }],
|
||||
...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}),
|
||||
} as Email;
|
||||
}
|
||||
|
||||
describe('sender warning in avatars (bulwarkmail/webmail#1439)', () => {
|
||||
it('asks for the Authentication-Results headers in the list fetch, not in the full one', () => {
|
||||
expect(EMAIL_LIST_PROPERTIES).toContain('header:Authentication-Results:all');
|
||||
// The full fetch has `headers`, which carry the same header.
|
||||
expect(EMAIL_FULL_PROPERTIES).not.toContain('header:Authentication-Results:all');
|
||||
expect(EMAIL_FULL_PROPERTIES).toContain('headers');
|
||||
});
|
||||
|
||||
it('judges a list row from the listed header and a full message from its headers', () => {
|
||||
expect(senderWarningOf(row([FORGED]))?.status).toBe('failed');
|
||||
expect(senderWarningOf(row([GOOD]))).toBeNull();
|
||||
expect(senderWarningOf(row(undefined))).toBeNull();
|
||||
expect(senderWarningOf(row([]))).toBeNull();
|
||||
const full = { ...row(undefined), headers: [{ name: 'Authentication-Results', value: ` ${FORGED}` }] };
|
||||
expect(senderWarningOf(full)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('shows the shield before a contact photo or favicon', () => {
|
||||
expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showFavicon: true })).toBe('warning');
|
||||
expect(avatarContent({ senderWarning: undefined, hasPhoto: true, showFavicon: true })).toBe('photo');
|
||||
expect(avatarContent({ senderWarning: null, hasPhoto: false, showFavicon: true })).toBe('favicon');
|
||||
expect(avatarContent({ hasPhoto: false, showFavicon: false })).toBe('initials');
|
||||
});
|
||||
|
||||
it('passes the warning to the avatar in the reader, collapsed conversation cards and list rows', () => {
|
||||
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8');
|
||||
expect(src('components/email/MessageHeader.tsx')).toMatch(/senderWarning=\{verificationText\?\.label\}/);
|
||||
expect(src('components/email/ThreadMessageCard.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
|
||||
expect(src('screens/EmailListScreen.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
|
||||
expect(src('screens/UnifiedInboxScreen.tsx')).toMatch(/senderWarning=\{verification \?/);
|
||||
});
|
||||
});
|
||||
@@ -172,7 +172,12 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
||||
>
|
||||
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
|
||||
<SenderAvatar
|
||||
name={from?.name}
|
||||
email={from?.email}
|
||||
size={compact ? componentSizes.avatarSm : componentSizes.avatarMd}
|
||||
senderWarning={verificationText?.label}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={styles.info}>
|
||||
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import SenderAvatar from '../SenderAvatar';
|
||||
import { MessageContent, type MessageContentProps } from './MessageContent';
|
||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
|
||||
import { singleLine } from '../../lib/single-line';
|
||||
import { useSenderWarning } from './use-sender-warning';
|
||||
|
||||
interface Props extends MessageContentProps {
|
||||
expanded: boolean;
|
||||
@@ -43,11 +44,12 @@ export function ThreadMessageCard({
|
||||
const unread = !email.keywords?.$seen;
|
||||
const starred = !!email.keywords?.$flagged;
|
||||
const date = emailDisplayDate(email);
|
||||
const senderWarning = useSenderWarning(expanded ? undefined : email);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<Pressable onPress={onToggleExpanded} style={({ pressed }) => [styles.collapsed, pressed && styles.pressed]}>
|
||||
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} />
|
||||
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} senderWarning={senderWarning} />
|
||||
<View style={styles.collapsedInfo}>
|
||||
<View style={styles.collapsedTop}>
|
||||
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import type { Email } from '../../api/types';
|
||||
import { describeSenderVerification, senderWarningOf } from '../../lib/email-headers';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
/**
|
||||
* The sender-check label of a message whose From the receiving server's
|
||||
* checks don't back, the same verdict as the notice in the reader. Avatars
|
||||
* show it instead of the claimed sender's favicon or photo (webmail
|
||||
* use-sender-warning.ts, #1439). Undefined when the check passed, there are
|
||||
* no results to judge by, or no message is given (Sent and Drafts rows show
|
||||
* the recipient, whose avatar the check says nothing about).
|
||||
*/
|
||||
export function useSenderWarning(email: Pick<Email, 'headers' | 'from'> | undefined): string | undefined {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const verification = React.useMemo(() => (email ? senderWarningOf(email) : null), [email]);
|
||||
return verification ? describeSenderVerification(verification, t).label : undefined;
|
||||
}
|
||||
@@ -196,3 +196,24 @@ export function getContactPhotoForEmail(
|
||||
const key = email?.trim().toLowerCase();
|
||||
return key ? getContactPhotoIndex(contacts).get(key) : undefined;
|
||||
}
|
||||
|
||||
// --- What the avatar shows --------------------------------------------------
|
||||
|
||||
export type AvatarContent = 'warning' | 'photo' | '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).
|
||||
*/
|
||||
export function avatarContent(options: {
|
||||
senderWarning?: string | null;
|
||||
hasPhoto: boolean;
|
||||
showFavicon: boolean;
|
||||
}): AvatarContent {
|
||||
if (options.senderWarning) return 'warning';
|
||||
if (options.hasPhoto) return 'photo';
|
||||
if (options.showFavicon) return 'favicon';
|
||||
return 'initials';
|
||||
}
|
||||
|
||||
@@ -244,6 +244,28 @@ export function senderVerificationOf(email: Pick<Email, 'headers' | 'from'>): Se
|
||||
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list fetch asks for the Authentication-Results headers by this
|
||||
* property, so list rows and collapsed conversation cards can judge the
|
||||
* sender too (webmail 605a3983b, #1439). Stalwart answers header properties
|
||||
* from the stored headers, not the message blob.
|
||||
*/
|
||||
export const LIST_AUTH_RESULTS_PROPERTY = 'header:Authentication-Results:all';
|
||||
|
||||
/**
|
||||
* The sender check for a message as the list or the reader holds it: from
|
||||
* the full headers when they are there, else from the list property. Null
|
||||
* when the checks back the From or there is nothing to judge by.
|
||||
*/
|
||||
export function senderWarningOf(email: Pick<Email, 'headers' | 'from'>): SenderVerification | null {
|
||||
if (email.headers) return senderVerificationOf(email);
|
||||
const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY];
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const values = raw.filter((value): value is string => typeof value === 'string');
|
||||
if (values.length === 0) return null;
|
||||
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
|
||||
}
|
||||
|
||||
interface ResInfo {
|
||||
method: string;
|
||||
result: string;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ListAttachmentChips, ListAttachmentOpener, useListRowAttachments,
|
||||
} from '../components/email/ListAttachmentChips';
|
||||
import { VerificationCodeChip, useListVerificationCode } from '../components/email/VerificationCodeChip';
|
||||
import { useSenderWarning } from '../components/email/use-sender-warning';
|
||||
import type { LoadListAttachments } from '../lib/list-attachments';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import {
|
||||
@@ -176,6 +177,8 @@ const EmailRow = React.memo(function EmailRow({
|
||||
const handlePress = React.useCallback(() => onPress(key), [onPress, key]);
|
||||
const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]);
|
||||
const verificationCode = useListVerificationCode(item);
|
||||
// Sent and Drafts rows show the recipient: the check says nothing about them.
|
||||
const senderWarning = useSenderWarning(showRecipient ? undefined : item);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -215,6 +218,7 @@ const EmailRow = React.memo(function EmailRow({
|
||||
email={senderEmail}
|
||||
size={componentSizes.avatarMd}
|
||||
disableImages={disableAvatarImages}
|
||||
senderWarning={senderWarning}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { singleLine } from '../lib/single-line';
|
||||
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
|
||||
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
||||
import { onStateChangeType } from '../lib/state-change-bus';
|
||||
import { describeSenderVerification, senderWarningOf } from '../lib/email-headers';
|
||||
import { createUnifiedReloadTracker } from '../lib/unified-reload';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
@@ -313,6 +314,8 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
const unread = !item.keywords?.$seen;
|
||||
const starred = !!item.keywords?.$flagged;
|
||||
const isSelected = selected.has(rowKey(item));
|
||||
// Sent and Drafts rows show the recipient: the check says nothing about them.
|
||||
const verification = showRecipient ? null : senderWarningOf(item);
|
||||
return (
|
||||
<SwipeableRow
|
||||
leftAction={selectionMode ? 'none' : swipeLeftAction}
|
||||
@@ -344,6 +347,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email}
|
||||
size={componentSizes.avatarMd}
|
||||
disableImages={role === 'junk' && !showAvatarsInJunk}
|
||||
senderWarning={verification ? describeSenderVerification(verification, t).label : undefined}
|
||||
/>
|
||||
{acc && accountIds.length > 1 && (
|
||||
<View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />
|
||||
|
||||
Reference in New Issue
Block a user