diff --git a/locales/rn/en.json b/locales/rn/en.json index 84ffef24..802dfea7 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1327,6 +1327,13 @@ } } }, + "shared_tags": { + "description": "Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.", + "empty": "No shared tags yet.", + "loading": "Loading…", + "title": "Shared tags", + "unsupported": "This mail server can't keep shared tags, so this mailbox shows your own tags." + }, "sharing": { "directory_failed": "Couldn't load the list of users and groups.", "directory_not_browsable": "Your server doesn't let you browse its user directory.", diff --git a/src/api/__tests__/bulwark-metadata.test.ts b/src/api/__tests__/bulwark-metadata.test.ts new file mode 100644 index 00000000..384c856f --- /dev/null +++ b/src/api/__tests__/bulwark-metadata.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const METADATA = 'urn:ietf:params:jmap:metadata'; + +const { state } = vi.hoisted(() => ({ + state: { + session: null as unknown, + requests: [] as Array<{ calls: Array<[string, Record, string]>; using?: string[] }>, + answer: null as null | ((calls: Array<[string, Record, string]>) => unknown), + }, +})); + +vi.mock('../jmap-client', () => ({ + jmapClient: { + serverUrl: 'https://mail.example.org', + get currentSession() { + return state.session; + }, + request: vi.fn(async (calls: Array<[string, Record, string]>, using?: string[]) => { + state.requests.push({ calls, using }); + return state.answer!(calls); + }), + }, +})); + +import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata'; + +const VENDOR = { supportsVendorNamespaces: true, supportsPrivate: true, namespaces: [], maxDepth: 8 }; + +// Stalwart 1.0.0 lists Metadata per account; 0.16 has none. +function session(version: '1.0' | '0.16') { + const caps = version === '1.0' ? { [METADATA]: { dataTypes: { Mailbox: VENDOR, Email: VENDOR } } } : {}; + return { + capabilities: version === '1.0' ? { [METADATA]: {} } : {}, + accounts: { + own: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } }, + team: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } }, + }, + }; +} + +// As Stalwart 1.0.0 answers the query and the get (checked live 2026-10-29). +function inboxWith(metadata: unknown) { + return () => ({ + methodResponses: [ + ['Mailbox/query', { accountId: 'team', ids: ['a'] }, 'q'], + ['Mailbox/get', { accountId: 'team', list: [{ id: 'a', ...(metadata === undefined ? {} : { metadata }) }] }, 'g'], + ], + }); +} + +beforeEach(() => { + state.requests = []; + state.answer = null; + resetBulwarkMetadataPauses(); +}); + +describe('getBulwarkMetadata', () => { + it('sends nothing to an account that advertises no metadata (Stalwart 0.16)', async () => { + state.session = session('0.16'); + expect(supportsBulwarkMetadata('team')).toBe(false); + expect(await getBulwarkMetadata('team')).toBeNull(); + expect(state.requests).toHaveLength(0); + }); + + it("reads the Inbox's Bulwark namespaces in a request of its own", async () => { + state.session = session('1.0'); + state.answer = inboxWith({ + 'org.bulwarkmail.tags': { tags: { urgent: { label: 'Urgent', color: 'red' } } }, + 'com.example.other': { x: 1 }, + }); + + expect(await getBulwarkMetadata('team')).toEqual({ tags: { tags: { urgent: { label: 'Urgent', color: 'red' } } } }); + expect(state.requests).toHaveLength(1); + const [{ calls, using }] = state.requests; + expect(using).toContain(METADATA); + expect(calls.map(([name]) => name)).toEqual(['Mailbox/query', 'Mailbox/get']); + expect(calls[0][1]).toEqual({ accountId: 'team', filter: { role: 'inbox' } }); + expect(calls[1][1].properties).toEqual(['id', 'metadata']); + }); + + it('takes an answer without the property for no metadata, and pauses the account', async () => { + state.session = session('1.0'); + state.answer = inboxWith(undefined); + expect(await getBulwarkMetadata('team')).toBeNull(); + expect(supportsBulwarkMetadata('team')).toBe(false); + expect(await getBulwarkMetadata('team')).toBeNull(); + expect(state.requests).toHaveLength(1); + // Another account is not paused with it. + expect(supportsBulwarkMetadata('own')).toBe(true); + }); + + it('pauses an account whose request fails (the pre-release build dropped it)', async () => { + state.session = session('1.0'); + state.answer = () => { throw new Error('socket hang up'); }; + expect(await getBulwarkMetadata('team')).toBeNull(); + expect(supportsBulwarkMetadata('team')).toBe(false); + }); +}); diff --git a/src/api/__tests__/jmap-metadata-requests.test.ts b/src/api/__tests__/jmap-metadata-requests.test.ts index b8d119c7..216f01bd 100644 --- a/src/api/__tests__/jmap-metadata-requests.test.ts +++ b/src/api/__tests__/jmap-metadata-requests.test.ts @@ -239,11 +239,18 @@ describe('JMAP Metadata in the request-building sources', () => { /(['"`])metadata(\/[^'"`\n]*)?\1/, ]; + // Stalwart 1.0.0 serves Metadata. The one module that reads it (a group's + // shared tags) asks only accounts that advertise it, in a request of its + // own; its tests cover that. + const METADATA_READER = 'src/api/bulwark-metadata.ts'; + it('never names a JMAP Metadata property, argument or capability in code', () => { const files = [...sourceFiles('src'), 'App.tsx']; expect(files).toContain('src/api/email.ts'); + expect(files).toContain(METADATA_READER); const hits: string[] = []; for (const file of files) { + if (file === METADATA_READER) continue; const code = withoutComments(readFileSync(path.join(root, file), 'utf8')); for (const pattern of FORBIDDEN) { const match = code.match(pattern); diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts index 8c1b7666..97f33d58 100644 --- a/src/api/__tests__/live-stalwart-mail.test.ts +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -48,6 +48,8 @@ import { createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript, } from '../sieve'; import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media'; +import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata'; +import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags'; import { generateVCard, parseVCard } from '../../lib/vcard'; import type { ContactCard } from '../types'; @@ -352,6 +354,43 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => { expect(first).toHaveProperty('myRights'); }, 60_000); + // Shared tags (#1310) live in a group's Inbox metadata. The test login + // is in no group, so this checks the shape on its own Inbox: written the + // way the webmail writes it, read the way the app reads it. + it('reads shared tags from Inbox metadata on 1.0 and asks nothing of 0.16', async () => { + resetBulwarkMetadataPauses(); + const accountId = client.accountId; + if (!is10()) { + expect(supportsBulwarkMetadata(accountId)).toBe(false); + expect(await getBulwarkMetadata(accountId)).toBeNull(); + return; + } + const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata']; + const inbox = (await getMailboxes()).find((m) => m.role === 'inbox')!.id; + const before = await getBulwarkMetadata(accountId); + expect(before).not.toBeNull(); + const setTags = (value: unknown, path = '') => client.request([ + ['Mailbox/set', { accountId, update: { [inbox]: { [`metadata/org.bulwarkmail.tags${path}`]: value } } }, '0'], + ], using); + try { + const written = await setTags({ tags: { [`live-${RUN}`]: { label: 'Live check', color: 'teal-dark', note: 'kept' } } }); + expect(written.methodResponses[0][1].updated).toHaveProperty(inbox); + // One tag patched on its own, as the webmail saves a change. + await setTags({ label: 'Second', color: 'blue' }, `/tags/second-${RUN}`); + + resetBulwarkMetadataPauses(); + const entries = await getBulwarkMetadata(accountId); + expect(parseSharedTags(entries?.[SHARED_TAGS_KEY])).toEqual([ + { id: `live-${RUN}`, label: 'Live check', color: 'teal' }, + { id: `second-${RUN}`, label: 'Second', color: 'blue' }, + ]); + } finally { + await setTags(before?.[SHARED_TAGS_KEY] ?? null); + } + const after = await getBulwarkMetadata(accountId); + expect(after?.[SHARED_TAGS_KEY]).toEqual(before?.[SHARED_TAGS_KEY]); + }, 60_000); + it('creates, reads, validates and deletes a Sieve script', async () => { const content = `require ["fileinto"];\r\n# ${RUN} */\r\nif header :contains "subject" "${RUN}" { fileinto "INBOX"; }\r\n`; expect((await validateSieveScript(content)).isValid).toBe(true); diff --git a/src/api/__tests__/unified-inbox-filter.test.ts b/src/api/__tests__/unified-inbox-filter.test.ts index 44a9fb28..2ddede99 100644 --- a/src/api/__tests__/unified-inbox-filter.test.ts +++ b/src/api/__tests__/unified-inbox-filter.test.ts @@ -44,3 +44,28 @@ describe('buildFilter (aggregated views)', () => { }); }); }); + +describe('buildFilter: searching from All mail', () => { + const all = [...MAILBOXES, mailbox('arch', 'archive'), mailbox('drafts', 'drafts'), mailbox('spam', 'junk')]; + + it('searches Sent, Archive and Drafts too, and only leaves Trash and Junk out', () => { + expect(buildFilter(all, { view: 'all', query: 'budget' })).toEqual({ + operator: 'AND', + conditions: [{ inMailboxOtherThan: ['bin', 'spam'] }, { text: 'budget' }], + }); + }); + + it('keeps the All mail folders without a query, and Unread narrows to its list', () => { + expect(buildFilter(all, { view: 'all' })).toEqual({ + operator: 'OR', conditions: [{ inMailbox: 'in' }, { inMailbox: 'projects' }], + }); + expect(buildFilter(all, { view: 'unread', query: 'budget' })).toEqual({ + operator: 'AND', + conditions: [ + { operator: 'OR', conditions: [{ inMailbox: 'in' }, { inMailbox: 'projects' }] }, + { notKeyword: '$seen' }, + { text: 'budget' }, + ], + }); + }); +}); diff --git a/src/api/bulwark-metadata.ts b/src/api/bulwark-metadata.ts new file mode 100644 index 00000000..dedb8bf2 --- /dev/null +++ b/src/api/bulwark-metadata.ts @@ -0,0 +1,91 @@ +// Bulwark's own data in JMAP Metadata (RFC draft, urn:ietf:params:jmap:metadata) +// of an account's Inbox: the shared tag definitions of a group account +// (namespace `org.bulwarkmail.tags`). Port of the reading half of the +// webmail's lib/jmap/bulwark-metadata.ts and client.getBulwarkMetadata. +// +// Stalwart 1.0.0 serves Metadata; 0.16 advertises none, and a pre-release +// 1.0 build advertised it but dropped the connection on a Mailbox/get that +// selects it (42751a6b). So this is the only module that asks for it: only +// for an account whose capabilities offer vendor namespaces on mailboxes, +// in a request of its own, and any failure pauses that account for ten +// minutes. Every other Mailbox/get keeps its explicit property list. + +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; + +export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata'; +export const BULWARK_NAMESPACE = 'org.bulwarkmail'; + +const PAUSE_AFTER_FAILURE_MS = 10 * 60_000; + +/** + * Whether the account's capabilities offer vendor-namespaced metadata on + * mailboxes. Says nothing about whether the server really implements it. + */ +export function advertisesMailboxMetadata(accountCapabilities: Record | undefined): boolean { + const cap = accountCapabilities?.[METADATA_CAPABILITY] as + | { dataTypes?: Record } + | undefined; + return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true; +} + +/** Bulwark's entries of a `metadata` object, by key (`tags`, `template.`). */ +export function bulwarkEntries(metadata: unknown): Record { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {}; + const prefix = `${BULWARK_NAMESPACE}.`; + const out: Record = {}; + for (const [namespace, value] of Object.entries(metadata as Record)) { + if (namespace.startsWith(prefix)) out[namespace.slice(prefix.length)] = value; + } + return out; +} + +// Accounts whose last read failed, by server and account, with when. +const failedAt = new Map(); +const pauseKey = (accountId: string) => `${jmapClient.serverUrl ?? ''}|${accountId}`; + +/** Whether the app may ask this account for its Inbox metadata now. */ +export function supportsBulwarkMetadata(accountId: string): boolean { + const failed = failedAt.get(pauseKey(accountId)); + if (failed !== undefined && Date.now() - failed < PAUSE_AFTER_FAILURE_MS) return false; + const account = jmapClient.currentSession?.accounts?.[accountId] as + | { accountCapabilities?: Record } + | undefined; + return advertisesMailboxMetadata(account?.accountCapabilities); +} + +/** + * Bulwark's shared metadata entries of the account's Inbox, by key. Null + * when the account has none to offer (0.16) or the read failed; no request + * goes out for an account that doesn't advertise Metadata. + */ +export async function getBulwarkMetadata(accountId: string): Promise | null> { + if (!supportsBulwarkMetadata(accountId)) return null; + const property = 'metadata'; + try { + const res = await jmapClient.request([ + ['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'], + ['Mailbox/get', { + accountId, + '#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' }, + properties: ['id', property], + }, 'g'], + ], [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY]); + const get = res.methodResponses?.find((r) => r[0] === 'Mailbox/get'); + const inbox = (get?.[1] as { list?: Array> } | undefined)?.list?.[0]; + // An answer without the property is a server that doesn't keep it, + // never "every entry was removed". + if (!inbox || !(property in inbox)) throw new Error('No Inbox metadata in the answer'); + failedAt.delete(pauseKey(accountId)); + return bulwarkEntries(inbox[property]); + } catch (err) { + console.warn('[bulwark-metadata] unavailable for account', accountId, err); + failedAt.set(pauseKey(accountId), Date.now()); + return null; + } +} + +/** Forget the failure pauses (sign-out, tests). */ +export function resetBulwarkMetadataPauses(): void { + failedAt.clear(); +} diff --git a/src/api/unified-inbox.ts b/src/api/unified-inbox.ts index f583acc9..7bc53957 100644 --- a/src/api/unified-inbox.ts +++ b/src/api/unified-inbox.ts @@ -304,7 +304,15 @@ export function buildFilter( opts: UnifiedFetchOptions, ): Record | null { const conditions: Record[] = []; - if (opts.view) { + const q = opts.query?.trim(); + if (opts.view === 'all' && q) { + // Searching from All mail searches every folder but Trash and Junk, the + // default search scope: the All mail list leaves Sent, Archive and + // Drafts out, and narrowing the search to it hid every sent reply + // (webmail lib/unified-mailbox.ts searchCrossViewEmails). + const skipped = mailboxes.filter((m) => m.role === 'trash' || m.role === 'junk' || m.role === 'spam'); + if (skipped.length > 0) conditions.push({ inMailboxOtherThan: skipped.map((m) => m.id) }); + } else if (opts.view) { const included = crossIncludedMailboxes(mailboxes); if (included.length === 0) return null; conditions.push( @@ -321,7 +329,6 @@ export function buildFilter( } // One condition per quoted phrase or `word*` (see splitSearchText), in the // same AND as the folder conditions. - const q = opts.query?.trim(); if (q) conditions.push(...textConditions(q)); return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions }; } diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index bc2068f9..be2b4b8b 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -5,7 +5,7 @@ import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Image as ImageIcon, ShieldCheck } from './icons'; import type { Email } from '../api/types'; -import { extractCidRefs } from '../lib/email-html'; +import { extractCidRefs, isUserLinkNavigation } from '../lib/email-html'; import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body'; import { buildQuoteCollapseScript } from '../lib/quote-collapse'; import { bodyDocument } from '../lib/email-body-document'; @@ -13,6 +13,7 @@ import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/b import { parseMailtoUrl } from '../lib/unsubscribe'; import { fetchInlineImageDataUri } from '../lib/email-export'; import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders'; +import { senderVerificationOf } from '../lib/email-headers'; import { useHasContacts } from '../lib/capabilities'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; @@ -563,7 +564,15 @@ export default function EmailBodyView({ }, [email, bodyOverride]); const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]); const text = picked.text; - const trusted = isSenderContentTrusted(senderEmail, { + // A From address the server's checks don't back may be forged: neither + // honour an earlier "Always trust" for it nor offer one, or a forgery of a + // trusted sender would load remote content (and trusting it would let the + // next forgery do the same). + const unverifiedSender = React.useMemo( + () => senderVerificationOf(email) !== null, + [email.headers, email.from], + ); + const trusted = !unverifiedSender && isSenderContentTrusted(senderEmail, { isLocallyTrusted: isSenderTrusted, syncEnabled: syncTrustedSenders, trustedBookEmails: trustedSenderEmails, @@ -768,7 +777,7 @@ export default function EmailBodyView({ {t('email_viewer.load_external_content', 'Load images')} )} - {senderEmail ? ( + {senderEmail && !unverifiedSender ? ( {t('email_viewer.trust_sender', 'Always trust this sender')} @@ -834,6 +843,11 @@ export default function EmailBodyView({ // any other) navigation would replace the email body with no way // back, so it is refused; images are reachable via the chips. if (!url || url === 'about:blank') return true; + // A message opens links only when the user taps one. The page + // runs no script of its own, so nothing else should navigate; + // iOS says how a navigation started, so anything but a tap is + // refused there as well. Android always reports 'other'. + if (!isUserLinkNavigation(request, Platform.OS)) return false; if (/^mailto:/i.test(url)) { openMailto(url); return false; diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index aab8d8ab..f36a270b 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -46,6 +46,7 @@ import { type NotificationMutes, } from '../lib/notification-mutes'; import { serverSupportsEmailPush } from '../lib/push-notifications'; +import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon'; const CHEVRON_SLOT = 20; const INDENT_STEP = 12; @@ -64,41 +65,28 @@ const UNIFIED_ROLES: UnifiedRole[] = ['sent', 'drafts', 'archive', 'junk', 'tras // in for them (#495). const HIDDEN_WITH_VIRTUAL_ROW = new Set(['scheduled']); -// Distinct icons for the special-use roles (#288), then a name-based guess -// for servers that don't set roles, then a plain folder. -function iconFor( - role: string | null | undefined, - name: string | undefined, - hasChildren: boolean, - isExpanded: boolean, -): AppIcon { - switch (role) { - case 'inbox': return Inbox; - case 'sent': return Send; - case 'drafts': return FileIcon; - case 'trash': return Trash2; - case 'junk': - case 'spam': return Ban; - case 'archive': return Archive; - case 'important': return Flag; - case 'flagged': return Star; - case 'all': return Mails; - case 'memos': return StickyNote; - case 'scheduled': return Clock; - case 'snoozed': return AlarmClock; - case 'shared': return Users; - default: break; - } - const lower = (name ?? '').toLowerCase(); - if (lower.includes('inbox')) return Inbox; - if (lower.includes('sent')) return Send; - if (lower.includes('draft')) return FileIcon; - if (lower.includes('trash') || lower.includes('deleted')) return Trash2; - if (lower.includes('junk') || lower.includes('spam')) return Ban; - if (lower.includes('archive')) return Archive; - if (lower.includes('star') || lower.includes('flag')) return Star; - if (hasChildren) return isExpanded ? FolderOpen : Folder; - return Folder; +// Distinct icons for the special-use roles (#288), else a plain folder: by +// role only, never by name (lib/mailbox-icon.ts, #80). +const ICONS: Record = { + inbox: Inbox, + sent: Send, + drafts: FileIcon, + trash: Trash2, + junk: Ban, + archive: Archive, + important: Flag, + flagged: Star, + all: Mails, + memos: StickyNote, + scheduled: Clock, + snoozed: AlarmClock, + shared: Users, + folder: Folder, + 'folder-open': FolderOpen, +}; + +function iconFor(role: string | null | undefined, hasChildren: boolean, isExpanded: boolean): AppIcon { + return ICONS[mailboxIconKind(role, hasChildren, isExpanded)]; } const ROLE_COLOR_FIXED: Record = { @@ -745,7 +733,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side }; }, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]); - const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, undefined, false, false); + const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, false, false); const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; @@ -1139,7 +1127,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side // tapping it only opens or closes that account's folders. const Icon = node.isAccountNode ? Users - : iconFor(node.role, node.name, hasChildren, isExpanded); + : iconFor(node.role, hasChildren, isExpanded); const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId; return ( {attachmentPosition === 'beside-sender' && chips} diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index bd599b3c..4fdcbcca 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -9,15 +9,18 @@ import { useColors } from '../../theme/colors'; import SenderAvatar from '../SenderAvatar'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; -import { useKeywordsStore } from '../../stores/keywords-store'; +import { useAccountKeywordDefs } from '../../stores/keywords-store'; import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date'; import { deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, + getSenderVerification, describeSenderVerification, type AuthenticationResults, type EmailHeaderInfo, } from '../../lib/email-headers'; import { formatSize } from '../../lib/attachment-display'; import { isSmimeEmail } from '../../lib/smime'; import { resolveEmailTags } from '../../lib/thread-utils'; +import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code'; +import { VerificationCodeChip } from './VerificationCodeChip'; interface Props { email: Email; @@ -27,6 +30,8 @@ interface Props { onAddressPress: (address: EmailAddress) => void; /** Compact variant for collapsed thread cards. */ compact?: boolean; + /** JMAP account the message lives in (a group's shared tags name its tags). */ + jmapAccountId?: string; } function addressLabel(a: EmailAddress): string { @@ -91,13 +96,14 @@ function DetailRow({ label, value, styles, mono }: { label: string; value?: stri * "Show details" panel (recipients & routing, authentication, identifiers, * mailing list, properties) the webmail viewer shows. */ -export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact }: Props) { +export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact, jmapAccountId }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const locale = useLocaleStore((s) => s.locale); const timeFormat = useSettingsStore((s) => s.timeFormat); - const keywordDefs = useKeywordsStore((s) => s.keywords); + const keywordDefs = useAccountKeywordDefs(jmapAccountId ?? email.jmapAccountId); + const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes); const [showDetails, setShowDetails] = React.useState(false); React.useEffect(() => { setShowDetails(false); }, [email.id]); @@ -106,6 +112,24 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA const starred = !!email.keywords?.$flagged; const displayDate = emailDisplayDate(email); const spoofed = isAuthenticationSpoofed(info.auth); + // The server's checks don't back the visible From domain: say so next to + // the name and above the body, not only in the collapsed details. + const verification = React.useMemo( + () => getSenderVerification(info.auth, from?.email), + [info.auth, from?.email], + ); + const verificationText = verification ? describeSenderVerification(verification, t) : null; + // The sign-in code of the message, offered for copying. Looked up again + // only when the message or its loaded body changes, not on a keyword change. + const verificationCode = React.useMemo( + () => (showVerificationCodes && !compact + ? findVerificationCode(email.subject, verificationCodeBodyText(email)) + : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [showVerificationCodes, compact, email.id, email.subject, email.bodyValues], + ); + const verificationColor = verification?.status === 'failed' ? c.error : c.warning; + const verificationBg = verification?.status === 'failed' ? c.errorBg : c.warningBg; // "via ": the message was sent by one of the user's identities or // received at one of them (incl. +tag); hidden when the From can't be @@ -159,6 +183,15 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA {from.email} ) : null} + {verificationText && ( + + + {verificationText.label} + + )} {!compact && ( <> @@ -234,6 +267,21 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA )} + {verificationCode && } + + {!compact && verificationText && ( + + + + {verificationText.label} + {verificationText.message} + + {t('email_viewer.sender_check.caution', 'Be careful with links, attachments and requests for passwords or payment details.')} + + + + )} + {!compact && ( setShowDetails((v) => !v)} style={styles.detailsToggle} hitSlop={6}> {showDetails ? : } @@ -345,6 +393,19 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.surfaceHover, }, badgeDot: { width: 8, height: 8, borderRadius: 4 }, + verificationBadge: { alignSelf: 'flex-start', marginTop: 4 }, + verificationNotice: { + flexDirection: 'row', + gap: spacing.sm, + padding: spacing.md, + borderWidth: 1, + borderRadius: radius.md, + backgroundColor: c.surface, + }, + verificationBody: { flex: 1, minWidth: 0, gap: 2 }, + verificationLabel: { ...typography.small, fontWeight: '600' }, + verificationMessage: { ...typography.caption, color: c.text }, + verificationCaution: { ...typography.caption, color: c.textSecondary }, badgeText: { ...typography.small, color: c.textSecondary }, detailsToggle: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start' }, detailsToggleText: { ...typography.caption, color: c.primary, fontWeight: '600' }, diff --git a/src/components/email/UnsubscribeBanner.tsx b/src/components/email/UnsubscribeBanner.tsx index 8b6464a7..75e7d244 100644 --- a/src/components/email/UnsubscribeBanner.tsx +++ b/src/components/email/UnsubscribeBanner.tsx @@ -13,7 +13,9 @@ import { sendEmail } from '../../api/email'; import { pruneEmailSubmissions } from '../../api/submissions'; import { SubmissionQuotaError } from '../../api/jmap-result'; import { sendFailureMessage } from '../../lib/send-failure-message'; -import { isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe } from '../../lib/unsubscribe'; +import { + isValidUnsubscribeUrl, parseUnsubscribeMailto, isOneClickUnsubscribe, unsubscribeConfirmMessage, +} from '../../lib/unsubscribe'; import type { ListHeaders } from '../../lib/email-headers'; import { findReceivingIdentity } from '../../lib/email-headers'; import { mailboxesOfAccount } from '../../lib/mailbox-tree'; @@ -66,6 +68,12 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr const unsub = list.listUnsubscribe; const method = unsub?.preferred; const url = method === 'http' ? unsub?.http : unsub?.mailto; + // The sender wrote this URL: only one recipient and a short subject and + // body are sent, and the confirmation shows all of them first. + const mailtoFields = React.useMemo( + () => (method === 'mailto' && url ? parseUnsubscribeMailto(url) : null), + [method, url], + ); React.useEffect(() => { let cancelled = false; @@ -76,7 +84,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr return () => { cancelled = true; }; }, [messageKey]); - if (hidden || !url || !method) return null; + if (hidden || !url || !method || (method === 'mailto' && !mailtoFields)) return null; const dismiss = () => { dismissed.add(messageKey); @@ -100,7 +108,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr await WebBrowser.openBrowserAsync(url); } } else { - const fields = parseMailtoUrl(url); + const fields = mailtoFields; if (!fields) throw new Error('invalid mailto'); const identity = findReceivingIdentity(identities, email) ?? identities[0]; if (!identity) throw new Error(t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available')); @@ -112,8 +120,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr await sendEmail( { from: [{ name: identity.name, email: identity.email }], - to: fields.to.map((address) => ({ email: address })), - cc: fields.cc?.map((address) => ({ email: address })), + to: [{ email: fields.to[0] }], subject: fields.subject ?? '', textBody: fields.body ?? '', }, @@ -141,7 +148,10 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr t('email_viewer.unsubscribe_banner.confirm_title', 'Unsubscribe from this sender?'), method === 'http' ? t('email_viewer.unsubscribe_banner.confirm_message_http', 'The unsubscribe page will open in a new tab.') - : t('email_viewer.unsubscribe_banner.confirm_message_mailto', 'An unsubscribe email will be sent to the sender.'), + : unsubscribeConfirmMessage( + t('email_viewer.unsubscribe_banner.confirm_message_mailto', 'An unsubscribe email will be sent to the sender.'), + mailtoFields, + ), [ { text: t('email_viewer.unsubscribe_banner.cancel', 'Cancel'), style: 'cancel' }, { text: t('email_viewer.unsubscribe_banner.confirm_button', 'Confirm'), onPress: () => { void perform(); } }, diff --git a/src/components/email/VerificationCodeChip.tsx b/src/components/email/VerificationCodeChip.tsx new file mode 100644 index 00000000..5e3f5145 --- /dev/null +++ b/src/components/email/VerificationCodeChip.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { Check, Key } from '../icons'; +import type { Email } from '../../api/types'; +import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useSettingsStore } from '../../stores/settings-store'; +import { toast } from '../../stores/toast-store'; +import { listVerificationCode } from '../../lib/verification-code'; + +/** The code a list row offers: found in the subject and preview of a fresh mail. */ +export function useListVerificationCode(email: Pick): string | null { + const enabled = useSettingsStore((s) => s.showVerificationCodes); + const { subject, preview, receivedAt } = email; + return React.useMemo( + () => (enabled ? listVerificationCode({ subject, preview, receivedAt }) : null), + [enabled, subject, preview, receivedAt], + ); +} + +interface Props { + code: string; + /** A list row in selection mode: the tap selects the row instead. */ + disabled?: boolean; + /** Spacing for a list row (below the preview). */ + inList?: boolean; +} + +/** The one-time code of a sign-in mail (lib/verification-code.ts); a tap copies it. */ +export function VerificationCodeChip({ code, disabled, inList }: Props) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const [copied, setCopied] = React.useState(false); + + React.useEffect(() => { + if (!copied) return undefined; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + const copy = () => { + Clipboard.setStringAsync(code).then( + () => { + setCopied(true); + toast.success(t('email_viewer.verification_code.copied', 'Code copied')); + }, + () => toast.error(t('email_viewer.verification_code.copy_failed', 'Could not copy the code')), + ); + }; + + const Icon = copied ? Check : Key; + const label = t('email_viewer.verification_code.copy', 'Copy code {code}', { code }); + return ( + + [styles.chip, pressed && styles.chipPressed]} + accessibilityRole="button" + accessibilityLabel={label} + testID="verification-code-chip" + > + + {code} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + row: { flexDirection: 'row' }, + rowInList: { marginTop: spacing.xs }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + backgroundColor: c.surface, + }, + chipPressed: { backgroundColor: c.surfaceHover }, + code: { ...typography.caption, color: c.text, fontWeight: '600', fontFamily: 'monospace', letterSpacing: 0.5 }, + }); +} diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx index ce83d72e..0456a936 100644 --- a/src/components/settings/KeywordSettings.tsx +++ b/src/components/settings/KeywordSettings.tsx @@ -4,7 +4,9 @@ import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons' import { SettingsSection } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store'; +import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../../stores/keywords-store'; +import { useEmailStore } from '../../stores/email-store'; +import { supportsBulwarkMetadata } from '../../api/bulwark-metadata'; import { DARK_COLORS } from '../../theme/tokens'; import { useLocaleStore } from '../../stores/locale-store'; import { discoverKeywords } from '../../api/keyword-discovery'; @@ -203,10 +205,72 @@ export function KeywordSettings() { )} + ); } +/** + * The shared tags of each group mailbox in the sidebar (#1310): what every + * member sees on its mail, read from the group's Inbox metadata. Shown, not + * edited here; Bulwark on the web edits them. + */ +function SharedTagsSection() { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const mailboxes = useEmailStore((s) => s.mailboxes); + const sharedTags = useKeywordsStore((s) => s.sharedTags); + const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags); + const accounts = React.useMemo(() => { + const seen = new Map(); + for (const m of mailboxes) { + if (m.isShared && m.accountId && !seen.has(m.accountId)) seen.set(m.accountId, m.accountName ?? m.accountId); + } + return [...seen].map(([id, name]) => ({ id, name })); + }, [mailboxes]); + useEffect(() => { + for (const account of accounts) void loadSharedTags(account.id); + }, [accounts, loadSharedTags]); + + if (accounts.length === 0) return null; + const login = currentTagsLogin(); + return ( + + {t('shared_tags.title', 'Shared tags')} + + {t('shared_tags.description', 'Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.')} + + {accounts.map((account) => { + const entry = sharedTags[account.id]; + const tags = entry && entry.login === login ? entry.tags : null; + return ( + + {account.name} + {tags === null ? ( + + {supportsBulwarkMetadata(account.id) + ? t('shared_tags.loading', 'Loading…') + : t('shared_tags.unsupported', "This mail server can't keep shared tags, so this mailbox shows your own tags.")} + + ) : tags.length === 0 ? ( + {t('shared_tags.empty', 'No shared tags yet.')} + ) : ( + tags.map((kw) => ( + + + {kw.label} + $label:{kw.id} + + )) + )} + + ); + })} + + ); +} + interface KeywordFormProps { initial?: Keyword; existingIds: string[]; @@ -335,6 +399,10 @@ function makeStyles(c: ThemePalette) { outlineBtnText: { ...typography.caption, color: c.mutedForeground }, scanStatus: { ...typography.caption, color: c.mutedForeground, paddingTop: spacing.xs }, scanResults: { gap: spacing.sm }, + sharedBlock: { gap: spacing.sm, marginTop: spacing.lg }, + sharedTitle: { ...typography.bodySemibold, color: c.text }, + sharedAccount: { gap: spacing.sm }, + sharedAccountName: { ...typography.caption, color: c.textSecondary, fontWeight: '600' }, form: { gap: spacing.md, padding: spacing.md, diff --git a/src/components/settings/ReadingSettings.tsx b/src/components/settings/ReadingSettings.tsx index 3949f275..f345f226 100644 --- a/src/components/settings/ReadingSettings.tsx +++ b/src/components/settings/ReadingSettings.tsx @@ -32,6 +32,7 @@ export function ReadingSettings() { const deleteAction = useSettingsStore((s) => s.deleteAction); const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk); const showPreview = useSettingsStore((s) => s.showPreview); + const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes); const clearSearchOnFolderChange = useSettingsStore((s) => s.clearSearchOnFolderChange); const disableThreading = useSettingsStore((s) => s.disableThreading); const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified); @@ -193,6 +194,10 @@ export function ReadingSettings() { update('showPreview', v)} /> + + update('showVerificationCodes', v)} /> + + update('disableThreading', v)} /> diff --git a/src/lib/__tests__/editor-html.test.ts b/src/lib/__tests__/editor-html.test.ts index 9e4a3265..a5f192b6 100644 --- a/src/lib/__tests__/editor-html.test.ts +++ b/src/lib/__tests__/editor-html.test.ts @@ -5,7 +5,7 @@ import { import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens'; function extractScript(html: string): string { - const m = html.match(/