diff --git a/src/lib/__tests__/list-sender.test.ts b/src/lib/__tests__/list-sender.test.ts new file mode 100644 index 00000000..98b785b8 --- /dev/null +++ b/src/lib/__tests__/list-sender.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { listAddressLabel } from '../list-sender'; +import { toUnicodeEmailOnOwnDomains } from '../idn'; + +// A sender with no display name on one of the user's own IDN domains showed +// as punycode in the list rows (`info@xn--bcher-kva.de`), while the message +// header already read `info@bücher.de` (webmail thread-list-item.tsx, #1100). + +const own = ['me@xn--bcher-kva.de']; +const format = (address: string) => toUnicodeEmailOnOwnDomains(address, own); + +describe('listAddressLabel', () => { + it('reads a bare address on an own IDN domain as written, and leaves others alone', () => { + expect(listAddressLabel({ email: 'info@xn--bcher-kva.de' }, format, 'Unknown')).toBe('info@bücher.de'); + expect(listAddressLabel({ email: 'info@xn--mnchen-3ya.de' }, format, 'Unknown')).toBe('info@xn--mnchen-3ya.de'); + }); + + it('prefers the display name and falls back to the unknown label', () => { + expect(listAddressLabel({ name: 'Bücher', email: 'info@xn--bcher-kva.de' }, format, 'Unknown')).toBe('Bücher'); + expect(listAddressLabel(undefined, format, 'Unknown')).toBe('Unknown'); + expect(listAddressLabel({ name: '', email: '' }, format, 'Unknown')).toBe('Unknown'); + }); +}); + +describe('list rows', () => { + const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '../../screens', file), 'utf8'); + + it('name senders and recipients through the own-domain formatter', () => { + const list = read('EmailListScreen.tsx'); + // Read once by the screen and handed to the memoised rows. + expect(list).toMatch(/const formatAddress = useOwnDomainAddress\(\);/); + expect(list).toMatch(/formatAddress=\{formatAddress\}/); + expect(list.match(/listAddressLabel\([^)]*formatAddress/g)).toHaveLength(2); + expect(list).not.toMatch(/\.name \|\| [\w.?[\]]*\.email/); + + const unified = read('UnifiedInboxScreen.tsx'); + expect(unified).toMatch(/const formatAddress = useOwnDomainAddress\(\);/); + expect(unified.match(/listAddressLabel\([^)]*formatAddress/g)).toHaveLength(2); + expect(unified.match(/senderName\(item, [^)]*\), formatAddress\)/g)).toHaveLength(2); + expect(unified).not.toMatch(/\.name \|\| [\w.?[\]]*\.email/); + }); +}); diff --git a/src/lib/list-sender.ts b/src/lib/list-sender.ts new file mode 100644 index 00000000..96582684 --- /dev/null +++ b/src/lib/list-sender.ts @@ -0,0 +1,13 @@ +/** + * How a mail list row names an address: its display name, else the address + * itself, formatted by the caller (useOwnDomainAddress: the user's own IDN + * domains read as written, `bücher.de`), else the "unknown" label (webmail + * components/email/thread-list-item.tsx, #1100). + */ +export function listAddressLabel( + address: { name?: string | null; email?: string | null } | undefined, + formatAddress: (address: string) => string, + unknown: string, +): string { + return address?.name || (address?.email ? formatAddress(address.email) : '') || unknown; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index aa1c812c..700d3ae8 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -50,6 +50,8 @@ import { useContactsStore } from '../stores/contacts-store'; import { useOutboxStore } from '../stores/outbox-store'; import { toast } from '../stores/toast-store'; import { getContactDisplayName } from '../lib/contact-utils'; +import { listAddressLabel } from '../lib/list-sender'; +import { useOwnDomainAddress } from '../lib/use-own-domain-address'; import { formatListDate } from '../lib/date-format'; import { singleLine } from '../lib/single-line'; import { cleanPreview } from '../lib/preview-text'; @@ -85,21 +87,20 @@ function reportKeywordFailure(title: string, err: unknown): void { toast.error(title, err instanceof Error ? err.message : undefined); } -function getSenderName(email: Email, unknownLabel: string): string { - return email.from?.[0]?.name || email.from?.[0]?.email || unknownLabel; -} - -function getSenderEmail(email: Email): string | undefined { - return email.from?.[0]?.email; -} - -// Sent/Drafts rows name the recipient, not "me" (webmail 1.4.12). -function getCounterpart(email: Email, showRecipient: boolean, unknownLabel: string): { name: string; email?: string } { +// Sent/Drafts rows name the recipient, not "me" (webmail 1.4.12). A bare +// address on one of the user's own IDN domains reads as written (#1100). +function getCounterpart( + email: Email, + showRecipient: boolean, + unknownLabel: string, + formatAddress: (address: string) => string, +): { name: string; email?: string } { if (showRecipient) { const to = email.to?.[0] ?? email.cc?.[0] ?? email.bcc?.[0]; - if (to) return { name: to.name || to.email, email: to.email }; + if (to) return { name: listAddressLabel(to, formatAddress, unknownLabel), email: to.email }; } - return { name: getSenderName(email, unknownLabel), email: getSenderEmail(email) }; + const from = email.from?.[0]; + return { name: listAddressLabel(from, formatAddress, unknownLabel), email: from?.email }; } function isUnread(email: Email): boolean { @@ -136,6 +137,7 @@ const EmailRow = React.memo(function EmailRow({ threadCount, showPreview, showRecipient, + formatAddress, tagIds, keywordDefs, disableAvatarImages, @@ -153,6 +155,8 @@ const EmailRow = React.memo(function EmailRow({ threadCount: number; showPreview: boolean; showRecipient: boolean; + /** useOwnDomainAddress, read once by the list rather than per row. */ + formatAddress: (address: string) => string; /** Comma-joined tag ids of the row (thread union) — a string so memo holds. */ tagIds: string; keywordDefs: KeywordDef[]; @@ -181,6 +185,7 @@ const EmailRow = React.memo(function EmailRow({ item, showRecipient, tr('email_viewer.unknown_sender', 'Unknown'), + formatAddress, ); const unread = isUnread(item); const starred = isStarred(item); @@ -570,6 +575,7 @@ export default function EmailListScreen({ const folderKeywordDefs = (currentOwnerAccountId && keywordDefsByAccount.get(currentOwnerAccountId)) || keywordDefs; const inJunk = currentRole === 'junk' || currentRole === 'spam'; const showRecipient = currentRole === 'sent' || currentRole === 'drafts'; + const formatAddress = useOwnDomainAddress(); // When threading is on, collapse same-thread emails so the list shows the // newest message per thread with a count badge (pinned threads first). @@ -885,6 +891,7 @@ export default function EmailListScreen({ threadCount={threadCountFor(item)} showPreview={showPreview} showRecipient={showRecipient} + formatAddress={formatAddress} tagIds={rowTagIds.get(key) ?? ''} keywordDefs={(item.jmapAccountId && keywordDefsByAccount.get(item.jmapAccountId)) || folderKeywordDefs} disableAvatarImages={inJunk && !showAvatarsInJunk} @@ -903,7 +910,7 @@ export default function EmailListScreen({ [ selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor, - showPreview, showRecipient, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk, + showPreview, showRecipient, formatAddress, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk, loadAttachments, openAttachment, ], ); diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index 1855d9fd..553e4c9b 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -25,6 +25,8 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store'; import { useLocaleStore } from '../stores/locale-store'; import { formatListDate } from '../lib/date-format'; import { singleLine } from '../lib/single-line'; +import { listAddressLabel } from '../lib/list-sender'; +import { useOwnDomainAddress } from '../lib/use-own-domain-address'; import { cleanPreview } from '../lib/preview-text'; import { prefetchMessage, rememberRows } from '../lib/email-detail-cache'; import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm'; @@ -39,12 +41,18 @@ type Props = NativeStackScreenProps; const PAGE_SIZE = 25; -function senderName(email: UnifiedEmail, showRecipient: boolean, unknown: string): string { +// A bare address on one of the user's own IDN domains reads as written (#1100). +function senderName( + email: UnifiedEmail, + showRecipient: boolean, + unknown: string, + formatAddress: (address: string) => string, +): string { if (showRecipient) { const to = email.to?.[0]; - if (to) return to.name || to.email; + if (to) return listAddressLabel(to, formatAddress, unknown); } - return email.from?.[0]?.name || email.from?.[0]?.email || unknown; + return listAddressLabel(email.from?.[0], formatAddress, unknown); } function rowKey(e: UnifiedEmail): string { @@ -326,6 +334,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { const errorCount = Object.keys(errors).length; const showRecipient = role === 'sent' || role === 'drafts'; + const formatAddress = useOwnDomainAddress(); const title = view ? t(`sidebar.unified_all_${view === 'all' ? 'mail' : view}`, { all: 'All mail', unread: 'All unread', starred: 'All starred' }[view]) : role === 'inbox' @@ -366,7 +375,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { )} {density.showAvatar && - {senderName(item, showRecipient, t('email_viewer.unknown_sender', 'Unknown'))} + {senderName(item, showRecipient, t('email_viewer.unknown_sender', 'Unknown'), formatAddress)} {starred && } {item.hasAttachment && }