From 6da985a225ba20fb843dfdac034fc771abe5c522 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:08:11 +0100 Subject: [PATCH 1/9] fix: keep sender favicons off and style sheets out of notification previews (bulwarkmail/webmail#1136) --- .../__tests__/push-background-task.test.ts | 38 ++++++++++++++++++- src/lib/push-background-task.ts | 11 ++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index a61ffdc4..c90a53a6 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -325,6 +325,37 @@ describe('pushBackgroundTask notifications', () => { })); }); + it('drops a leading style sheet from the notification preview (webmail #1136)', async () => { + (secureFetch as ReturnType).mockImplementation(async (url: string) => ({ + ok: true, + json: async () => (url.endsWith('/.well-known/jmap') + ? { + apiUrl: 'https://mail.example.com/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' }, + accounts: { 'jmap-primary': {} }, + } + : { + methodResponses: [[ + 'Email/get', + { list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Sale', preview: '@media screen and (min-width:600px){.hide{display:none!important}} Our autumn sale\nstarts today', from: [{ name: 'Shop', email: 'news@shop.example' }] }] }, + '0', + ]], + }), + })); + + await pushBackgroundTask({ + kind: 'jmap-email-push', + accountLabel: 'alice', + accountId: 'jmap-primary', + emailIds: JSON.stringify(['m1']), + }); + + expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ + emailId: 'm1', + preview: 'Our autumn sale starts today', + })); + }); + it('handles markRead action via detached JMAP', async () => { const postCalls: any[] = []; (secureFetch as ReturnType).mockImplementation(async (url: string, opts?: any) => { @@ -759,9 +790,12 @@ describe('senderFaviconsAllowed', () => { expect(await senderFaviconsAllowed()).toBe(true); }); - it('defaults to on like the store, but skips icons when the settings are unreadable', async () => { + it('defaults to off like the store, and skips icons when the settings are unreadable', async () => { await AsyncStorage.removeItem(KEY); - expect(await senderFaviconsAllowed()).toBe(true); + expect(await senderFaviconsAllowed()).toBe(false); + // Settings saved before the key existed: the store fills in its default (off). + await AsyncStorage.setItem(KEY, JSON.stringify({ emailNotificationsEnabled: true })); + expect(await senderFaviconsAllowed()).toBe(false); await AsyncStorage.setItem(KEY, '{not json'); expect(await senderFaviconsAllowed()).toBe(false); await AsyncStorage.removeItem(KEY); diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index cecf9d2f..53c09bf7 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -13,6 +13,7 @@ import { getFaviconUrl, } from './avatar-utils'; import { singleLine } from './single-line'; +import { cleanPreview } from './preview-text'; import { lastNotifiedKey, migrateLegacyPushKeys, @@ -67,13 +68,14 @@ async function emailNotificationsAllowed(): Promise { // The "Sender Favicons" setting covers notification icons as well as the // in-app avatars: with it off the tray shows initials, and the sender's domain -// and the device's IP never reach the icon service. Off when unreadable. +// and the device's IP never reach the icon service. Off by default, like the +// store, and off when unreadable. export async function senderFaviconsAllowed(): Promise { try { const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY); - if (!raw) return true; // first launch: the store's default is on + if (!raw) return false; // first launch: the store's default is off const parsed = JSON.parse(raw) as PushPersistedSettings; - return parsed.senderFavicons !== false; + return parsed.senderFavicons === true; } catch { return false; } @@ -583,7 +585,8 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa const faviconDomain = favicons ? getFaviconDomain(address) : null; const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined; - const preview = email.preview ? singleLine(email.preview) : undefined; + // No leading style sheet or preheader padding (webmail #1136). + const preview = singleLine(cleanPreview(email.preview)) || undefined; const markReadLabel = translate(locale, 'email_viewer.mark_read', 'Mark as read'); const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete'); const replyLabel = translate(locale, 'email_viewer.reply', 'Reply'); From bb45198f23d1f16fc1568c8d2ce4cd85aa06291c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:09:12 +0100 Subject: [PATCH 2/9] feat: add a switch for the quick reply box under composing settings --- src/components/settings/ComposingSettings.tsx | 8 ++++++++ src/lib/__tests__/settings-search.test.ts | 1 + src/lib/settings-search.ts | 1 + 3 files changed, 10 insertions(+) diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index 28a8f46c..50d49c30 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -14,6 +14,7 @@ export function ComposingSettings() { const styles = React.useMemo(() => makeStyles(c), [c]); const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity); const replyIdentityMatch = useSettingsStore((s) => s.replyIdentityMatch); + const quickReplyEnabled = useSettingsStore((s) => s.quickReplyEnabled); const t = useLocaleStore((s) => s.t); const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity); const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled); @@ -104,6 +105,13 @@ export function ComposingSettings() { title={t('settings.composer.title', "Composer")} description={t('settings.composer.description', "Configure email composition settings")} > + + updateSetting('quickReplyEnabled', v)} /> + + { expect(labelsFor('notifications')).toContain('Parse email invitations'); expect(labelsFor('calendar')).toContain('Show week numbers'); expect(labelsFor('reading')).toContain('Clear search when switching folders'); + expect(labelsFor('composing')).toContain('Quick reply'); expect(labelsFor('notifications')).toContain('Unread count on app icon'); // Webmail-only settings are not offered. expect(labelsFor('calendar')).not.toContain('Free scrolling'); diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index a8927529..f3df5e96 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -104,6 +104,7 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.email_behavior.clear_search_on_folder_change', ], composing: [ + 'settings.email_behavior.quick_reply', 'settings.email_behavior.auto_select_reply_identity', 'settings.email_behavior.reply_identity_match', 'settings.email_behavior.plain_text_mode', From 97173d4af0e4320d64ac3daf7b8c1039c786b906 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:10:14 +0100 Subject: [PATCH 3/9] fix: lead with the address when two accounts share a name in the sidebar account switcher --- src/components/SidebarDrawer.tsx | 15 +++++++----- .../__tests__/sidebar-account-rows.test.ts | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 src/components/__tests__/sidebar-account-rows.test.ts diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 82326d9c..7b123aba 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -21,7 +21,8 @@ import { useAccountStore } from '../stores/account-store'; import { useSettingsStore } from '../stores/settings-store'; import { useKeywordsStore, keywordToken } from '../stores/keywords-store'; import { useLocaleStore } from '../stores/locale-store'; -import { MAX_ACCOUNTS } from '../lib/account-utils'; +import { MAX_ACCOUNTS, accountRowText } from '../lib/account-utils'; +import { toUnicodeEmail } from '../lib/idn'; import { buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode, } from '../lib/mailbox-tree'; @@ -857,9 +858,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side const isActive = acc.id === activeAccountId; let accHost = ''; try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; } - const accEmail = acc.email || (acc.username.includes('@') - ? acc.username - : accHost ? `${acc.username}@${accHost}` : acc.username); + // The address leads when another account has the same name + // (webmail 3bb9f7df8). + const rowText = accountRowText(acc, accounts); return ( - {acc.displayName || acc.username} + {toUnicodeEmail(rowText.title)} {acc.isDefault && ( )} - {accEmail} + {rowText.subtitle ? ( + {toUnicodeEmail(rowText.subtitle)} + ) : null} {acc.hasError ? ( <> diff --git a/src/components/__tests__/sidebar-account-rows.test.ts b/src/components/__tests__/sidebar-account-rows.test.ts new file mode 100644 index 00000000..6cf6bd63 --- /dev/null +++ b/src/components/__tests__/sidebar-account-rows.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Two accounts with the same name looked alike in the account switcher: both +// rows led with the shared name (webmail 3bb9f7df8). The rows now use +// accountRowText, which leads with the address then, and show IDN addresses +// as written. No React Native renderer in the test env, so this checks the +// wiring; accountRowText itself is covered in account-row-text.test.ts. + +describe('account switcher rows in the sidebar', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../SidebarDrawer.tsx'), 'utf8'); + + it('titles each row from accountRowText over all accounts', () => { + expect(src).toMatch(/const rowText = accountRowText\(acc, accounts\);/); + expect(src).toMatch(/\{toUnicodeEmail\(rowText\.title\)\}/); + expect(src).not.toMatch(/\{acc\.displayName \|\| acc\.username\}/); + }); + + it('shows the address line only when accountRowText gives one', () => { + expect(src).toMatch(/\{rowText\.subtitle \? \(\s*\{toUnicodeEmail\(rowText\.subtitle\)\}<\/Text>/); + expect(src).not.toMatch(/accEmail/); + }); +}); From 4932764a666d8aeed07a799eb51faebc5b2c395c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:14:55 +0100 Subject: [PATCH 4/9] fix: send to addresses on internationalized domains (bulwarkmail/webmail#1100) --- src/components/IdentitySheet.tsx | 3 +- .../__tests__/idn-recipients-wiring.test.ts | 34 +++++++++++++++++++ src/components/email/MessageHeader.tsx | 12 ++++--- src/components/email/ThreadMessageCard.tsx | 5 ++- src/lib/__tests__/recipients.test.ts | 23 +++++++++++++ src/lib/recipients.ts | 22 ++++++++++-- src/lib/use-own-domain-address.ts | 23 +++++++++++++ src/screens/ComposeScreen.tsx | 14 +++++--- 8 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 src/components/__tests__/idn-recipients-wiring.test.ts create mode 100644 src/lib/use-own-domain-address.ts diff --git a/src/components/IdentitySheet.tsx b/src/components/IdentitySheet.tsx index a4712c83..a14f7bbf 100644 --- a/src/components/IdentitySheet.tsx +++ b/src/components/IdentitySheet.tsx @@ -11,6 +11,7 @@ import type { Identity } from '../api/types'; import { useLocaleStore } from '../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { useSheetInsets } from '../lib/use-layout'; +import { toUnicodeEmail } from '../lib/idn'; interface IdentitySheetProps { visible: boolean; @@ -97,7 +98,7 @@ export function IdentitySheet({ )} - {identity.email} + {toUnicodeEmail(identity.email)} {isSelected && ( diff --git a/src/components/__tests__/idn-recipients-wiring.test.ts b/src/components/__tests__/idn-recipients-wiring.test.ts new file mode 100644 index 00000000..b137e655 --- /dev/null +++ b/src/components/__tests__/idn-recipients-wiring.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Addresses on internationalized domains (webmail #1100): the composer sends +// a domain typed in Unicode in its ASCII form, shows the From identity as +// written, and a sender on one of the user's own IDN domains reads as +// written in the reader. No React Native renderer in the test env, so this +// checks the wiring; outgoingAddress and toUnicodeEmailOnOwnDomains have unit +// tests in recipients.test.ts and idn.test.ts. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +describe('IDN addresses in the composer and reader', () => { + it('builds the outgoing To, Cc and Bcc through outgoingAddress', () => { + const src = read('screens/ComposeScreen.tsx'); + expect(src).toMatch(/function toAddress\(r: \{ name\?: string; email: string \}\): EmailAddress \{\s*return outgoingAddress\(r\);/); + expect(src).toMatch(/to: expandRecipients\(finalTo\)\.map\(toAddress\)/); + }); + + it('shows the From identity with its domain as written', () => { + expect(read('screens/ComposeScreen.tsx')).toMatch(/const email = toUnicodeEmail\(from\.email\);/); + expect(read('components/IdentitySheet.tsx')).toMatch(/\{toUnicodeEmail\(identity\.email\)\}/); + }); + + it('formats the sender address in the reader through useOwnDomainAddress', () => { + const header = read('components/email/MessageHeader.tsx'); + expect(header).toMatch(/const fromEmail = from\?\.email \? formatAddress\(from\.email\) : '';/); + expect(header).toMatch(/\{from\?\.name \|\| fromEmail \|\| t\('email_viewer\.unknown_sender'/); + expect(header).not.toMatch(/\{from\.email\}<\/Text>/); + const card = read('components/email/ThreadMessageCard.tsx'); + expect(card).toMatch(/\{from\?\.name \|\| \(from\?\.email && formatAddress\(from\.email\)\) \|\|/); + }); +}); diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index 123e0e67..5a07caf9 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -21,6 +21,7 @@ import { isSmimeEmail } from '../../lib/smime'; import { resolveEmailTags } from '../../lib/thread-utils'; import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code'; import { VerificationCodeChip } from './VerificationCodeChip'; +import { useOwnDomainAddress } from '../../lib/use-own-domain-address'; interface Props { email: Email; @@ -109,6 +110,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA const info = React.useMemo(() => headerInfo ?? deriveHeaderInfo(email), [headerInfo, email]); const from = email.from?.[0]; + // An address on one of the user's own IDN domains reads as written (#1100). + const formatAddress = useOwnDomainAddress(); + const fromEmail = from?.email ? formatAddress(from.email) : ''; const starred = !!email.keywords?.$flagged; const displayDate = emailDisplayDate(email); const spoofed = isAuthenticationSpoofed(info.auth); @@ -170,7 +174,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA onPress={from ? () => onAddressPress(from) : undefined} hitSlop={4} accessibilityRole="button" - accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} + accessibilityLabel={from?.name || fromEmail || t('email_viewer.unknown_sender', 'Unknown')} > onAddressPress(from) : undefined}> - {from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} + {from?.name || fromEmail || t('email_viewer.unknown_sender', 'Unknown')} - {from?.name && from?.email ? ( - {from.email} + {from?.name && fromEmail ? ( + {fromEmail} ) : null} {verificationText && ( diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index 51c822ac..95b5daf4 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -12,6 +12,7 @@ import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/ import { singleLine } from '../../lib/single-line'; import { cleanPreview } from '../../lib/preview-text'; import { useSenderWarning } from './use-sender-warning'; +import { useOwnDomainAddress } from '../../lib/use-own-domain-address'; interface Props extends MessageContentProps { expanded: boolean; @@ -46,6 +47,8 @@ export function ThreadMessageCard({ const starred = !!email.keywords?.$flagged; const date = emailDisplayDate(email); const senderWarning = useSenderWarning(expanded ? undefined : email); + // An address on one of the user's own IDN domains reads as written (#1100). + const formatAddress = useOwnDomainAddress(); if (!expanded) { return ( @@ -54,7 +57,7 @@ export function ThreadMessageCard({ - {from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} + {from?.name || (from?.email && formatAddress(from.email)) || t('email_viewer.unknown_sender', 'Unknown')} {email.hasAttachment && } {formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)} diff --git a/src/lib/__tests__/recipients.test.ts b/src/lib/__tests__/recipients.test.ts index d6790a7c..0fa42772 100644 --- a/src/lib/__tests__/recipients.test.ts +++ b/src/lib/__tests__/recipients.test.ts @@ -8,6 +8,7 @@ import { expandRecipients, splitPastedRecipients, parseMailtoUrl, + outgoingAddress, } from '../recipients'; describe('isValidEmail', () => { @@ -24,6 +25,28 @@ describe('isValidEmail', () => { expect(isValidEmail('')).toBe(false); expect(isValidEmail('noat')).toBe(false); }); + + it('accepts a domain written in Unicode when its ASCII form is valid (webmail #1100)', () => { + expect(isValidEmail('anna@bücher.de')).toBe(true); + expect(isValidEmail('ivan@пример.рф')).toBe(true); + expect(isValidEmail('anna@xn--bcher-kva.de')).toBe(true); + // Still no spaces, no non-ASCII local part, no bogus xn-- labels. + expect(isValidEmail('anna@bü cher.de')).toBe(false); + expect(isValidEmail('änna@bücher.de')).toBe(false); + expect(isValidEmail('anna@xn--zzzz-.de')).toBe(false); + expect(isValidEmail('anna@bücher.de\r\nBcc: x@y.z')).toBe(false); + }); +}); + +describe('outgoingAddress', () => { + it('sends a Unicode domain in its ASCII form and keeps the name', () => { + expect(outgoingAddress({ name: 'Anna', email: 'anna@bücher.de' })).toEqual({ name: 'Anna', email: 'anna@xn--bcher-kva.de' }); + expect(outgoingAddress({ email: 'ivan@пример.рф' })).toEqual({ email: 'ivan@xn--e1afmkfd.xn--p1ai' }); + }); + + it('leaves ASCII addresses and the local part alone', () => { + expect(outgoingAddress({ name: '', email: 'Anna.B@Example.org' })).toEqual({ email: 'Anna.B@Example.org' }); + }); }); describe('splitRecipients', () => { diff --git a/src/lib/recipients.ts b/src/lib/recipients.ts index f95dd8e6..7f05eeeb 100644 --- a/src/lib/recipients.ts +++ b/src/lib/recipients.ts @@ -4,6 +4,7 @@ // splitPastedRecipients, expandRecipients). All DOM-free. import { splitMailbox } from './rfc5322-mailbox'; +import { toAsciiEmail } from './idn'; /** * A composer recipient. Display name is optional; email is required - except @@ -19,14 +20,22 @@ export interface Recipient { /** * RFC 5322 compliant email validation with security enhancements. + * + * A domain written in Unicode (`anna@bücher.de`) counts when its ASCII form + * (`anna@xn--bcher-kva.de`) is valid; {@link outgoingAddress} sends that form + * (webmail #1100). The local part stays ASCII-only. */ export function isValidEmail(email: string): boolean { - // Length check - if (!email || email.length > 254) return false; + if (!email) return false; // Security: Block control characters and header injection if (/[\r\n\0<>]/.test(email)) return false; + email = toAsciiEmail(email); + + // Length check + if (email.length > 254) return false; + // RFC 5322 compliant regex (simplified but secure) const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; @@ -46,6 +55,15 @@ export function isValidEmail(email: string): boolean { return true; } +/** + * A recipient as it goes into the message: the address with its domain in + * ASCII (xn--) form, so a domain typed in Unicode reaches every server. + */ +export function outgoingAddress(r: { name?: string; email: string }): { name?: string; email: string } { + const email = toAsciiEmail(r.email); + return r.name ? { name: r.name, email } : { email }; +} + /** * True when the angle run that is open at `from` closes before the next one * opens. `from` is the index of the character under test (a separator, or the diff --git a/src/lib/use-own-domain-address.ts b/src/lib/use-own-domain-address.ts new file mode 100644 index 00000000..a60f2eeb --- /dev/null +++ b/src/lib/use-own-domain-address.ts @@ -0,0 +1,23 @@ +import React from 'react'; +import { useAuthStore } from '../stores/auth-store'; +import { useSettingsStore } from '../stores/settings-store'; +import { toUnicodeEmailOnOwnDomains } from './idn'; + +/** + * Formats an address from a message for display: on one of the signed-in + * user's own IDN domains it reads as written (`bücher.de`), anywhere else it + * stays as the message has it (see toUnicodeEmailOnOwnDomains; webmail + * hooks/use-own-domain-address.ts, #1100). + */ +export function useOwnDomainAddress(): (address: string) => string { + const username = useAuthStore((s) => s.username); + const identities = useSettingsStore((s) => s.identities); + const ownAddresses = React.useMemo( + () => [username, ...identities.map((i) => i.email)], + [username, identities], + ); + return React.useCallback( + (address: string) => toUnicodeEmailOnOwnDomains(address, ownAddresses), + [ownAddresses], + ); +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 02fc6bd6..dc67853d 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -60,8 +60,9 @@ import { import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format'; import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header'; import { - isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient, + isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient, } from '../lib/recipients'; +import { toUnicodeEmail } from '../lib/idn'; import { findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, } from '../lib/reply-identity'; @@ -188,8 +189,9 @@ function fromParsed(r: ParsedRecipient): Recipient { return { name: r.name ?? '', email: r.email, group: r.group }; } +// A domain typed in Unicode goes out in its ASCII form (webmail #1100). function toAddress(r: { name?: string; email: string }): EmailAddress { - return r.name ? { name: r.name, email: r.email } : { email: r.email }; + return outgoingAddress(r); } function chipIsValid(r: Recipient): boolean { @@ -2606,7 +2608,9 @@ export default function ComposeScreen({ route, navigation }: Props) { const fromDisplay = React.useMemo(() => { if (!primaryIdentity) return identityError ? t('email_composer.identity_unavailable', 'Identity unavailable') : t('common.loading', 'Loading...'); const { from } = senderAddress(primaryIdentity); - return from.name ? `${from.name} <${from.email}>` : from.email; + // An IDN domain arrives in its xn-- form; show it as written (#1100). + const email = toUnicodeEmail(from.email); + return from.name ? `${from.name} <${email}>` : email; }, [primaryIdentity, identityError, senderAddress, t]); const draftStatusText = @@ -3341,9 +3345,9 @@ export default function ComposeScreen({ route, navigation }: Props) { )} {!!primaryIdentity && ( - {t('identities.sub_address.preview_label', 'Preview:')} {tagDraft && !tagError + {t('identities.sub_address.preview_label', 'Preview:')} {toUnicodeEmail(tagDraft && !tagError ? generateSubAddress(primaryIdentity.email, tagDraft, subAddressDelimiter) - : primaryIdentity.email} + : primaryIdentity.email)} )} From 8ac19971497b2072839ca682df53511d9c8362ed Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:21:32 +0100 Subject: [PATCH 5/9] feat: delete filter rules, tags and trusted senders with undo and warn about the forward limit (bulwarkmail/webmail#1208) --- .../__tests__/live-stalwart-contacts.test.ts | 30 +++ .../settings/ContentSendersSettings.tsx | 30 +-- src/components/settings/FilterSettings.tsx | 45 +++-- src/components/settings/KeywordSettings.tsx | 19 +- src/lib/__tests__/settings-undo.test.ts | 190 ++++++++++++++++++ src/lib/settings-undo.ts | 101 ++++++++++ src/stores/keywords-store.ts | 9 + 7 files changed, 381 insertions(+), 43 deletions(-) create mode 100644 src/lib/__tests__/settings-undo.test.ts create mode 100644 src/lib/settings-undo.ts diff --git a/src/api/__tests__/live-stalwart-contacts.test.ts b/src/api/__tests__/live-stalwart-contacts.test.ts index 1f012675..83baa33a 100644 --- a/src/api/__tests__/live-stalwart-contacts.test.ts +++ b/src/api/__tests__/live-stalwart-contacts.test.ts @@ -45,6 +45,8 @@ import { import { updateAccountFilters } from '../sieve-rules'; import { useContactsStore } from '../../stores/contacts-store'; import { readVacationFilters, useFilterStore } from '../../stores/filter-store'; +import { useToastStore } from '../../stores/toast-store'; +import { deleteFilterRuleWithUndo } from '../../lib/settings-undo'; import { generateVCard, parseVCard } from '../../lib/vcard'; import { generateScript } from '../../lib/sieve/generator'; import { parseScript } from '../../lib/sieve/parser'; @@ -336,5 +338,33 @@ live('live Stalwart: contacts and filters (0.16 and 1.0)', () => { expect({ file, ...(await validateSieveScript(content)) }).toEqual({ file, isValid: true }); } }, 60_000); + + // Settings deletes a rule at once and offers Undo (webmail R3). + it('deletes a rule on the server at once and the toast Undo saves it back', async () => { + const rule = (id: string, name: string) => ({ + id, name, enabled: true, matchType: 'all' as const, + conditions: [{ field: 'from' as const, comparator: 'contains' as const, value: `${id}-${RUN}@example.com` }], + actions: [{ type: 'mark_read' as const, value: '' }], + stopProcessing: false, + }); + await updateSieveScript(created!, generateScript([rule('keep', `Keep ${RUN}`), rule('gone', `Gone ${RUN}`)]), true); + await useFilterStore.getState().selectAccount(null); + const gone = useFilterStore.getState().rules.find((r) => r.name === `Gone ${RUN}`)!; + expect(gone).toBeTruthy(); + useToastStore.getState().clearToasts(); + const serverContent = async () => + getSieveScriptContent((await getSieveScripts()).find((x) => x.id === created)!.blobId); + + await deleteFilterRuleWithUndo(gone.id, t); + expect(await serverContent()).not.toContain(`gone-${RUN}@example.com`); + expect(await serverContent()).toContain(`keep-${RUN}@example.com`); + const toast = useToastStore.getState().toasts.at(-1)!; + expect(toast.title).toBe('Filter rule deleted'); + + toast.action!.onPress(); + await expect.poll(async () => serverContent(), { timeout: 15_000 }).toContain(`gone-${RUN}@example.com`); + expect(useFilterStore.getState().rules.map((r) => r.name)).toEqual([`Keep ${RUN}`, `Gone ${RUN}`]); + expect(useToastStore.getState().toasts.some((x) => x.type === 'error')).toBe(false); + }, 60_000); }); }); diff --git a/src/components/settings/ContentSendersSettings.tsx b/src/components/settings/ContentSendersSettings.tsx index 6b340584..f8a14461 100644 --- a/src/components/settings/ContentSendersSettings.tsx +++ b/src/components/settings/ContentSendersSettings.tsx @@ -11,8 +11,10 @@ import { useSettingsStore, type ExternalContentPolicy } from '../../stores/setti import { useContactsStore } from '../../stores/contacts-store'; import { useHasContacts } from '../../lib/capabilities'; import { isTrustedSendersSyncOn } from '../../lib/trusted-senders'; +import { removeTrustedSenderWithUndo } from '../../lib/settings-undo'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { ToastHost } from '../ToastHost'; interface TrustedRow { email: string; @@ -107,20 +109,19 @@ export function ContentSendersSettings() { } }; + // Taking a sender off the list is reversible: no question, a toast with + // Undo that adds the address back (webmail trusted-senders-modal R3). const handleRemove = async (row: TrustedRow) => { - if (row.local) removeTrustedSender(row.email); - if (row.synced) { - setBusy(row.email); - try { - await removeFromTrustedSendersBook(row.email); - } catch (err) { - Alert.alert( - t('settings.email_behavior.trusted_senders.sync_remove_failed', 'Could not remove from address book'), - err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'), - ); - } finally { - setBusy(null); - } + if (row.synced) setBusy(row.email); + try { + await removeTrustedSenderWithUndo(row, { + removeLocal: removeTrustedSender, + addLocal: addTrustedSender, + removeFromBook: removeFromTrustedSendersBook, + addToBook: addToTrustedSendersBook, + }, t); + } finally { + if (row.synced) setBusy(null); } }; @@ -269,6 +270,9 @@ export function ContentSendersSettings() { )} + {/* The screen's toast host sits under this Modal; this one shows the + Undo after removing a sender above the list. */} + ); diff --git a/src/components/settings/FilterSettings.tsx b/src/components/settings/FilterSettings.tsx index 83d21b7d..983e0fbd 100644 --- a/src/components/settings/FilterSettings.tsx +++ b/src/components/settings/FilterSettings.tsx @@ -22,6 +22,8 @@ import { SieveEditorSheet } from '../filters/SieveEditorSheet'; import type { FilterRule } from '../../lib/sieve/types'; import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value'; import { periodStatus, supportsPeriods } from '../../lib/sieve/period'; +import { rulesForwardLimit } from '../../lib/sieve/forward-limit'; +import { deleteFilterRuleWithUndo } from '../../lib/settings-undo'; import Badge from '../Badge'; type Translate = (key: string, fallback?: string) => string; @@ -157,7 +159,8 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { const { rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings, includeVacation, - selectAccount, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule, + vacationForward, sieveCapabilities, + selectAccount, saveFilters, addRule, updateRule, reorderRules, toggleRule, setOpaqueScript, resetToVisualBuilder, validateScript, } = useFilterStore(); const periodsSupported = useFilterStore((s) => supportsPeriods(s.sieveCapabilities?.sieveExtensions)); @@ -200,23 +203,18 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { void persist(() => toggleRule(ruleId)); }, [toggleRule, persist]); + // A rule can be put back as it was, so it goes at once with an Undo + // (webmail R3). const handleDelete = useCallback((rule: FilterRule) => { - Alert.alert( - t('settings.filters.delete_rule', 'Delete Rule'), - t('settings.filters.delete_confirm', 'Are you sure you want to delete this rule?'), - [ - { text: t('settings.filters.cancel', 'Cancel'), style: 'cancel' }, - { - text: t('settings.filters.confirm_delete', 'Delete'), - style: 'destructive', - onPress: () => { - deleteRule(rule.id); - void persist(() => addRule(rule)); - }, - }, - ], - ); - }, [deleteRule, addRule, persist, t]); + void deleteFilterRuleWithUndo(rule.id, t); + }, [t]); + + // A message can collect only so many forwards on this server; the rules + // run behind the out of office forwarding (#1155, #1208). + const forwardLimit = useMemo( + () => rulesForwardLimit(rules, vacationForward, sieveCapabilities?.maxNumberRedirects), + [rules, vacationForward, sieveCapabilities], + ); const handleSaveRule = useCallback((rule: FilterRule) => { const previous = useFilterStore.getState().rules; @@ -362,6 +360,18 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { )} + {!isOpaque && forwardLimit.ignored && ( + + {t('settings.filters.forward_unavailable', 'This server does not allow forwarding. Forward actions in filter rules have no effect.')} + + )} + + {!isOpaque && forwardLimit.tooMany && ( + + {t('settings.filters.forward_limit', 'Forward limit per message on this server: {count}. Extra forwards are skipped.', { count: sieveCapabilities?.maxNumberRedirects ?? 0 })} + + )} + {!isOpaque && rules.length > 0 && ( {rules.map((rule) => { @@ -567,6 +577,7 @@ function makeStyles(c: ThemePalette) { emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md }, emptyText: { ...typography.body, color: c.mutedForeground }, + forwardWarning: { ...typography.caption, color: c.warning }, ruleRow: { flexDirection: 'row', diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx index a9c399db..83d867fb 100644 --- a/src/components/settings/KeywordSettings.tsx +++ b/src/components/settings/KeywordSettings.tsx @@ -12,6 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import { discoverKeywords } from '../../api/keyword-discovery'; import { findUnrecognizedKeywords, type UnrecognizedKeyword } from '../../lib/keyword-discovery'; import { jmapClient } from '../../api/jmap-client'; +import { deleteKeywordWithUndo } from '../../lib/settings-undo'; type Keyword = KeywordDef; @@ -26,7 +27,6 @@ export function KeywordSettings() { const t = useLocaleStore((s) => s.t); const addKeyword = useKeywordsStore((s) => s.add); const updateKeyword = useKeywordsStore((s) => s.update); - const removeKeyword = useKeywordsStore((s) => s.remove); const resetDefaults = useKeywordsStore((s) => s.resetDefaults); const hydrated = useKeywordsStore((s) => s.hydrated); const hydrate = useKeywordsStore((s) => s.hydrate); @@ -47,17 +47,10 @@ export function KeywordSettings() { } }; - // Deleting a tag takes it off every message's tag list on this device; ask - // first, as the reset next to it does. - const deleteKeyword = (id: string, label: string) => { - Alert.alert( - t('settings.keywords.delete_confirm_title', 'Delete tag "{label}"?', { label }), - undefined, - [ - { text: t('common.cancel', 'Cancel'), style: 'cancel' }, - { text: t('settings.keywords.delete', 'Delete tag'), style: 'destructive', onPress: () => removeKeyword(id) }, - ], - ); + // A tag can be put back as it was, so it goes at once with an Undo + // (webmail R3). + const deleteKeyword = (id: string) => { + deleteKeywordWithUndo(id, t); }; // One stray tap used to wipe a carefully built tag list (webmail removed @@ -130,7 +123,7 @@ export function KeywordSettings() { setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}> - deleteKeyword(kw.id, kw.label)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}> + deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}> diff --git a/src/lib/__tests__/settings-undo.test.ts b/src/lib/__tests__/settings-undo.test.ts new file mode 100644 index 00000000..33245d82 --- /dev/null +++ b/src/lib/__tests__/settings-undo.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../api/sieve', () => ({ + createSieveScript: vi.fn(), + getSieveAccountId: vi.fn(() => 'own'), + getSieveCapabilities: vi.fn(() => null), + getSieveScriptContent: vi.fn(), + getSieveScripts: vi.fn(), + isSieveSupported: vi.fn(() => true), + updateSieveScript: vi.fn(), + validateSieveScript: vi.fn(async () => ({ isValid: true })), +})); + +import fs from 'node:fs'; +import path from 'node:path'; +import * as sieve from '../../api/sieve'; +import { generateScript } from '../sieve/generator'; +import type { FilterRule } from '../sieve/types'; +import { useFilterStore } from '../../stores/filter-store'; +import { useKeywordsStore } from '../../stores/keywords-store'; +import { useToastStore } from '../../stores/toast-store'; +import { + deleteFilterRuleWithUndo, deleteKeywordWithUndo, removeTrustedSenderWithUndo, UNDO_TOAST_DURATION, +} from '../settings-undo'; + +// Deleting a filter rule, a tag or a trusted sender in Settings goes at once +// with an Undo in the toast instead of a confirm (webmail R3). + +const api = vi.mocked(sieve); +const t = (key: string, fallback?: string) => `${key}|${fallback ?? ''}`; +const toasts = () => useToastStore.getState().toasts; +const lastToast = () => toasts()[toasts().length - 1]; + +function rule(id: string, name: string): FilterRule { + return { + id, name, enabled: true, matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: `${id}@example.com` }], + actions: [{ type: 'move', value: 'Archive' }], + stopProcessing: false, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + api.getSieveAccountId.mockReturnValue('own'); + api.isSieveSupported.mockReturnValue(true); + api.getSieveCapabilities.mockReturnValue(null); + useFilterStore.getState().clearState(); + useToastStore.getState().clearToasts(); +}); + +describe('deleteFilterRuleWithUndo', () => { + async function load() { + api.getSieveScripts.mockResolvedValue([{ id: 's1', name: 'filters', blobId: 'b1', isActive: true }]); + api.getSieveScriptContent.mockResolvedValue(generateScript([rule('a', 'A'), rule('b', 'B')])); + await useFilterStore.getState().selectAccount(null); + } + + it('deletes and saves without asking, and the toast Undo saves the rule back', async () => { + await load(); + api.updateSieveScript.mockResolvedValue(undefined); + + await deleteFilterRuleWithUndo('a', t); + expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['b']); + expect(api.updateSieveScript).toHaveBeenCalledTimes(1); + expect(lastToast()).toMatchObject({ type: 'success', title: 'notifications.filters_deleted|Filter rule deleted' }); + expect(lastToast().action?.label).toBe('notifications.rule_undo|Undo'); + + lastToast().action!.onPress(); + await vi.waitFor(() => expect(api.updateSieveScript).toHaveBeenCalledTimes(2)); + expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'b']); + }); + + it('says the save failed and keeps the rule when the server refuses', async () => { + await load(); + api.updateSieveScript.mockRejectedValueOnce(new Error('down')); + + await deleteFilterRuleWithUndo('a', t); + expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'b']); + expect(toasts()).toHaveLength(1); + expect(lastToast()).toMatchObject({ type: 'error', title: 'notifications.filters_save_failed|Failed to save filters' }); + }); + + it('says the save failed when the Undo cannot be saved', async () => { + await load(); + api.updateSieveScript.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('down')); + await deleteFilterRuleWithUndo('a', t); + lastToast().action!.onPress(); + await vi.waitFor(() => expect(lastToast().type).toBe('error')); + expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['b']); + }); +}); + +describe('deleteKeywordWithUndo', () => { + beforeEach(() => { + useKeywordsStore.setState({ + keywords: [ + { id: 'red', label: 'Red', color: 'red' }, + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'home', label: 'Home', color: 'green' }, + ], + } as never); + }); + + it('deletes the tag at once and Undo puts it back where it was', () => { + deleteKeywordWithUndo('work', t); + expect(useKeywordsStore.getState().keywords.map((k) => k.id)).toEqual(['red', 'home']); + expect(lastToast()).toMatchObject({ type: 'success', title: 'settings.keywords.deleted|Tag deleted' }); + + lastToast().action!.onPress(); + expect(useKeywordsStore.getState().keywords.map((k) => k.id)).toEqual(['red', 'work', 'home']); + // A second tap does not add it twice. + lastToast().action!.onPress(); + expect(useKeywordsStore.getState().keywords.map((k) => k.id)).toEqual(['red', 'work', 'home']); + }); +}); + +describe('removeTrustedSenderWithUndo', () => { + function stores() { + return { + removeLocal: vi.fn(), + addLocal: vi.fn(), + removeFromBook: vi.fn(async () => undefined), + addToBook: vi.fn(async () => undefined), + }; + } + + it('removes the sender from the list and the book, and Undo adds it back to both', async () => { + const s = stores(); + await removeTrustedSenderWithUndo({ email: 'ann@example.com', local: true, synced: true }, s, t); + expect(s.removeFromBook).toHaveBeenCalledWith('ann@example.com'); + expect(s.removeLocal).toHaveBeenCalledWith('ann@example.com'); + expect(lastToast()).toMatchObject({ + type: 'success', + title: 'settings.email_behavior.trusted_senders.removed|Removed from trusted senders', + message: 'ann@example.com', + duration: UNDO_TOAST_DURATION, + }); + + lastToast().action!.onPress(); + expect(s.addLocal).toHaveBeenCalledWith('ann@example.com'); + expect(s.addToBook).toHaveBeenCalledWith('ann@example.com'); + }); + + it('only touches the places the sender was in', async () => { + const s = stores(); + await removeTrustedSenderWithUndo({ email: 'bo@example.com', local: true, synced: false }, s, t); + lastToast().action!.onPress(); + expect(s.removeFromBook).not.toHaveBeenCalled(); + expect(s.addToBook).not.toHaveBeenCalled(); + expect(s.addLocal).toHaveBeenCalledWith('bo@example.com'); + }); + + it('changes nothing and says so when the address book cannot be written', async () => { + const s = stores(); + s.removeFromBook.mockRejectedValueOnce(new Error('down')); + await removeTrustedSenderWithUndo({ email: 'ann@example.com', local: true, synced: true }, s, t); + expect(s.removeLocal).not.toHaveBeenCalled(); + expect(toasts()).toHaveLength(1); + expect(lastToast()).toMatchObject({ type: 'error', title: "settings.email_behavior.trusted_senders.remove_error|Couldn't remove the sender" }); + }); +}); + +// No React Native renderer in the test env: the panes are checked for the +// wiring. +describe('settings panes', () => { + const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '../../components/settings', file), 'utf8'); + + it('filters: deletes with Undo and warns about the forward limit (#1155, #1208)', () => { + const src = read('FilterSettings.tsx'); + expect(src).toMatch(/void deleteFilterRuleWithUndo\(rule\.id, t\)/); + expect(src).not.toMatch(/settings\.filters\.delete_confirm/); + expect(src).toMatch(/rulesForwardLimit\(rules, vacationForward, sieveCapabilities\?\.maxNumberRedirects\)/); + expect(src).toMatch(/forwardLimit\.ignored && \(\s*]*>\s*\{t\('settings\.filters\.forward_unavailable'/); + expect(src).toMatch(/forwardLimit\.tooMany && \(\s*]*>\s*\{t\('settings\.filters\.forward_limit'/); + }); + + it('tags: deletes with Undo', () => { + const src = read('KeywordSettings.tsx'); + expect(src).toMatch(/deleteKeywordWithUndo\(id, t\)/); + expect(src).not.toMatch(/delete_confirm_title/); + }); + + it('trusted senders: removes with Undo, shown above the list modal', () => { + const src = read('ContentSendersSettings.tsx'); + expect(src).toMatch(/await removeTrustedSenderWithUndo\(row, \{/); + expect(src).not.toMatch(/sync_remove_failed/); + expect(src).toMatch(/\s*<\/Modal>/); + }); +}); diff --git a/src/lib/settings-undo.ts b/src/lib/settings-undo.ts new file mode 100644 index 00000000..0ed01b76 --- /dev/null +++ b/src/lib/settings-undo.ts @@ -0,0 +1,101 @@ +// Deleting things in Settings that can be put back as they were: they go at +// once, with an Undo in the toast, instead of behind a confirm (webmail R3: +// filter-settings.tsx handleDelete, keyword-settings.tsx handleDelete, +// trusted-senders-modal.tsx handleRemoveSender). + +import { toast } from '../stores/toast-store'; +import { useFilterStore } from '../stores/filter-store'; +import { useKeywordsStore } from '../stores/keywords-store'; + +type Translate = (key: string, fallback?: string, params?: Record) => string; + +/** How long a toast offering Undo stays (webmail lib/email-action-toast.ts). */ +export const UNDO_TOAST_DURATION = 8000; + +/** Deletes a filter rule on the server at once; the toast's Undo saves it back. */ +export async function deleteFilterRuleWithUndo(ruleId: string, t: Translate): Promise { + const failed = () => toast.error(t('notifications.filters_save_failed', 'Failed to save filters')); + let undo: (() => Promise) | null; + try { + undo = await useFilterStore.getState().deleteRuleNow(ruleId); + } catch { + failed(); + return; + } + if (!undo) return; + const putBack = undo; + toast.success(t('notifications.filters_deleted', 'Filter rule deleted'), { + action: { + label: t('notifications.rule_undo', 'Undo'), + onPress: () => { void putBack().catch(failed); }, + }, + }); +} + +/** Deletes a tag at once; the toast's Undo puts it back where it was. */ +export function deleteKeywordWithUndo(id: string, t: Translate): void { + const before = useKeywordsStore.getState().keywords; + const index = before.findIndex((k) => k.id === id); + if (index < 0) return; + const removed = before[index]; + useKeywordsStore.getState().remove(id); + toast.success(t('settings.keywords.deleted', 'Tag deleted'), { + action: { + label: t('notifications.rule_undo', 'Undo'), + onPress: () => { + // Added again meanwhile (same id): nothing to put back. + if (useKeywordsStore.getState().keywords.some((k) => k.id === id)) return; + useKeywordsStore.getState().insert(removed, index); + }, + }, + }); +} + +export interface TrustedSenderRemoval { + email: string; + /** On the list kept in the settings. */ + local: boolean; + /** In the synced "Trusted Senders" address book. */ + synced: boolean; +} + +export interface TrustedSenderStores { + removeLocal: (email: string) => void; + addLocal: (email: string) => void; + removeFromBook: (email: string) => Promise; + addToBook: (email: string) => Promise; +} + +/** + * Takes a sender off the trusted list (the settings list and the synced + * address book, wherever it is); the toast's Undo adds it back to the same + * places. Nothing changes when the address book can't be written. + */ +export async function removeTrustedSenderWithUndo( + row: TrustedSenderRemoval, + stores: TrustedSenderStores, + t: Translate, +): Promise { + try { + if (row.synced) await stores.removeFromBook(row.email); + } catch { + toast.error(t('settings.email_behavior.trusted_senders.remove_error', "Couldn't remove the sender")); + return; + } + if (row.local) stores.removeLocal(row.email); + toast.success(t('settings.email_behavior.trusted_senders.removed', 'Removed from trusted senders'), { + message: row.email, + duration: UNDO_TOAST_DURATION, + action: { + label: t('notifications.undo', 'Undo'), + onPress: () => { + if (row.local) stores.addLocal(row.email); + if (row.synced) { + void stores.addToBook(row.email).catch(() => { + toast.error(t('settings.email_behavior.trusted_senders.save_error', 'Failed to save - check the Contacts debug log for details')); + }); + } + }, + }, + }); +} diff --git a/src/stores/keywords-store.ts b/src/stores/keywords-store.ts index edb7595a..ab27669b 100644 --- a/src/stores/keywords-store.ts +++ b/src/stores/keywords-store.ts @@ -67,6 +67,8 @@ interface KeywordsState { add: (kw: KeywordDef) => void; update: (id: string, patch: Partial>) => void; remove: (id: string) => void; + /** Puts a tag back at `index` (the Undo after deleting it). */ + insert: (kw: KeywordDef, index: number) => void; resetDefaults: () => void; } @@ -167,6 +169,13 @@ export const useKeywordsStore = create((set, get) => ({ persist(next); }, + insert: (kw, index) => { + const next = [...get().keywords]; + next.splice(Math.min(Math.max(index, 0), next.length), 0, kw); + set({ keywords: next }); + persist(next); + }, + resetDefaults: () => { set({ keywords: DEFAULT_KEYWORDS }); persist(DEFAULT_KEYWORDS); From a7a3757378096f04478752cf89a945722f6629e4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:27:34 +0100 Subject: [PATCH 6/9] feat: suggest directory people and search the sent folder for recipients in the composer --- .../__tests__/live-stalwart-compose.test.ts | 40 ++++++++ .../__tests__/recipient-suggestions.test.ts | 92 ++++++++++++++++++ src/lib/recipient-suggestions.ts | 64 +++++++++++++ src/screens/ComposeScreen.tsx | 93 +++++++++++++++++-- 4 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 src/lib/__tests__/recipient-suggestions.test.ts create mode 100644 src/lib/recipient-suggestions.ts diff --git a/src/api/__tests__/live-stalwart-compose.test.ts b/src/api/__tests__/live-stalwart-compose.test.ts index 1fb53247..e7912e08 100644 --- a/src/api/__tests__/live-stalwart-compose.test.ts +++ b/src/api/__tests__/live-stalwart-compose.test.ts @@ -38,6 +38,8 @@ import { htmlToPlainText } from '../../lib/compose-html'; import { buildEmbeddedSignatureHtml, spliceSignature } from '../../lib/signature-utils'; import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files'; import { servedLoginOf } from '../../lib/draft-login'; +import { mergeRecipientSuggestions } from '../../lib/recipient-suggestions'; +import { useContactsStore } from '../../stores/contacts-store'; import { useAccountStore } from '../../stores/account-store'; import { useTemplatesStore } from '../../stores/templates-store'; @@ -315,6 +317,44 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => { }, 60_000); }); + // The composer's suggestions: "Search the server" finds a recipient in + // the Sent folder the recent list never loaded, and directory people are + // offered (parity 06). + describe('recipient suggestions', () => { + it('finds a Sent recipient on demand and suggests people from the directory', async () => { + const rcpt = `rcpt-${RUN}@example.net`; + const saved = await saveDraftVersion({ + from: [{ email: USER }], to: [{ name: `Rcpt ${RUN}`, email: rcpt }], subject: `suggest ${tag}`, textBody: 'hi', + }, sent); + createdEmails.push(saved.id); + useContactsStore.getState().reset(); + useContactsStore.setState({ sentMailboxId: sent, recentRecipientsLoaded: true }); + + const local = useContactsStore.getState().getAutocomplete(`rcpt-${RUN}`, 16); + expect(local).toEqual([]); + // The server indexes a new message shortly after it is stored. + let server: Awaited['searchRecipients']>> = []; + await vi.waitFor(async () => { + server = await useContactsStore.getState().searchRecipients(`rcpt-${RUN}`); + expect(server.map((s) => s.email)).toContain(rcpt); + }, { timeout: 20_000, interval: 1_000 }); + const merged = mergeRecipientSuggestions({ + local, isContact: () => false, directory: [], server, exclude: new Set(), limit: 8, + }); + expect(merged).toEqual([{ name: `Rcpt ${RUN}`, email: rcpt }]); + + await useContactsStore.getState().fetchDirectory(); + const me = USER.split('@')[0]; + const withDirectory = mergeRecipientSuggestions({ + local: [], isContact: () => false, + directory: useContactsStore.getState().lookupDirectory(me, 16), + server: [], exclude: new Set(), limit: 8, + }); + expect(withDirectory.map((s) => s.email.toLowerCase())).toContain(USER.toLowerCase()); + useContactsStore.getState().reset(); + }, 60_000); + }); + describe('the body of a composed message (compose pass 2026-11)', () => { it('keeps the quote marks, numbered list, signature delimiter and image text of the text part, and one signature', async () => { // The composer's body after a From switch with text typed under the diff --git a/src/lib/__tests__/recipient-suggestions.test.ts b/src/lib/__tests__/recipient-suggestions.test.ts new file mode 100644 index 00000000..8af072e6 --- /dev/null +++ b/src/lib/__tests__/recipient-suggestions.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mergeRecipientSuggestions } from '../recipient-suggestions'; +import type { RecipientSuggestion } from '../../stores/contacts-store'; + +// The composer's suggestions in the webmail's order: contacts and groups, +// directory people, recent recipients, then what "Search the server" found +// in the Sent folder (parity 06 autocomplete row). + +const contact = (name: string, email: string): RecipientSuggestion => ({ name, email }); +const contactEmails = new Set(['ann@example.com', 'nameless@example.com']); +const isContact = (s: RecipientSuggestion) => !!s.group || contactEmails.has(s.email); + +describe('mergeRecipientSuggestions', () => { + it('puts directory people after contacts and before recent recipients', () => { + const out = mergeRecipientSuggestions({ + local: [contact('Ann', 'ann@example.com'), contact('Recent Rob', 'rob@example.com')], + isContact, + directory: [contact('Dana Directory', 'dana@example.com')], + server: [], + exclude: new Set(), + limit: 8, + }); + expect(out.map((s) => s.email)).toEqual(['ann@example.com', 'dana@example.com', 'rob@example.com']); + }); + + it('lists an address once and lends a directory name to a contact without one', () => { + const out = mergeRecipientSuggestions({ + local: [contact('', 'nameless@example.com'), contact('Recent Dana', 'dana@example.com')], + isContact, + directory: [contact('Nora Nameless', 'Nameless@example.com'), contact('Dana Directory', 'dana@example.com')], + server: [], + exclude: new Set(), + limit: 8, + }); + expect(out).toEqual([ + { name: 'Nora Nameless', email: 'nameless@example.com' }, + { name: 'Dana Directory', email: 'dana@example.com' }, + ]); + }); + + it('adds server hits below the rest, skipping known and selected addresses', () => { + const local = Array.from({ length: 10 }, (_, i) => contact(`Recent ${i}`, `r${i}@example.com`)); + const out = mergeRecipientSuggestions({ + local, + isContact, + directory: [], + server: [contact('R0 again', 'r0@example.com'), contact('Picked', 'picked@example.com'), contact('Found', 'found@example.com'), contact('R9', 'r9@example.com')], + exclude: new Set(['picked@example.com']), + limit: 8, + }); + // Eight local ones, then the server's new ones; r9 fell past the limit + // and comes back from the server. + expect(out.map((s) => s.email)).toEqual([ + ...local.slice(0, 8).map((s) => s.email), 'found@example.com', 'r9@example.com', + ]); + }); + + it('keeps groups and leaves out addresses already on the message', () => { + const group: RecipientSuggestion = { name: 'Team', email: '', group: { id: 'g1', memberCount: 3 } }; + const out = mergeRecipientSuggestions({ + local: [group, contact('Ann', 'ann@example.com')], + isContact, + directory: [contact('Ann', 'ANN@example.com')], + server: [], + exclude: new Set(['ann@example.com']), + limit: 8, + }); + expect(out).toEqual([group]); + }); +}); + +// No React Native renderer in the test env: the composer is checked for the +// wiring. +describe('composer suggestions wiring', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8'); + + it('merges contacts, directory people and server hits', () => { + expect(src).toMatch(/mergeRecipientSuggestions\(\{\s*local: getAutocomplete\(q, 16\),/); + expect(src).toMatch(/directory: lookupDirectory\(q, 16\),/); + expect(src).toMatch(/server: serverHits\?\.query === q \? serverHits\.results : \[\],/); + expect(src).toMatch(/if \(directoryStatus === 'idle' && !loginMovedFrom\(ownerLoginRef\.current\)\) void fetchDirectory\(\);/); + }); + + it('offers "Search the server" when a Sent folder is known, on the composer\'s login only', () => { + expect(src).toMatch(/const results = await searchRecipients\(q\);/); + expect(src).toMatch(/if \(!q \|\| searchingServer \|\| loginMovedFrom\(ownerLoginRef\.current\)\) return;/); + expect(src).toMatch(/onSearchServer=\{canSearchServer \?/); + expect(src).toMatch(/t\('email_composer\.autocomplete_search_server', 'Search the server'\)/); + }); +}); diff --git a/src/lib/recipient-suggestions.ts b/src/lib/recipient-suggestions.ts new file mode 100644 index 00000000..275196a2 --- /dev/null +++ b/src/lib/recipient-suggestions.ts @@ -0,0 +1,64 @@ +import type { RecipientSuggestion } from '../stores/contacts-store'; + +// The composer's recipient suggestions, in the webmail's order +// (stores/contact-store.ts getAutocomplete, email-composer.tsx +// handleServerSearch): contacts and groups, then people in the company +// directory (RFC 9670 principals), then recent recipients from the Sent +// folder, then what an on-demand search of the Sent folder found. An address +// shows once, first where it comes up; a directory entry lends its name to a +// contact suggestion that has none. + +export interface SuggestionSources { + /** getAutocomplete's answer: contacts and groups first, then recent recipients. */ + local: RecipientSuggestion[]; + /** Whether a local suggestion is a contact or a group (the rest are recent recipients). */ + isContact: (s: RecipientSuggestion) => boolean; + /** lookupDirectory's answer. */ + directory: RecipientSuggestion[]; + /** searchRecipients' answer for the same query, once asked for. */ + server: RecipientSuggestion[]; + /** Addresses already on the message (lowercase); groups are always offered. */ + exclude: ReadonlySet; + /** How many contact, directory and recent suggestions to show. Server hits come on top. */ + limit: number; +} + +export function mergeRecipientSuggestions(src: SuggestionSources): RecipientSuggestion[] { + const out: RecipientSuggestion[] = []; + const seen = new Map(); + const offer = (s: RecipientSuggestion): boolean => { + if (s.group) { + out.push(s); + return true; + } + const key = s.email.trim().toLowerCase(); + if (!key || seen.has(key) || src.exclude.has(key)) return false; + const copy = { ...s }; + seen.set(key, copy); + out.push(copy); + return true; + }; + + const contacts = src.local.filter((s) => src.isContact(s)); + const recent = src.local.filter((s) => !src.isContact(s)); + for (const s of contacts) offer(s); + for (const s of src.directory) { + const known = seen.get(s.email.trim().toLowerCase()); + if (known) { + if (!known.name && s.name) known.name = s.name; + continue; + } + offer(s); + } + for (const s of recent) offer(s); + const shown = out.slice(0, src.limit); + // What fell past the limit may come back from the server search. + const shownKeys = new Set(shown.filter((s) => !s.group).map((s) => s.email.trim().toLowerCase())); + for (const s of src.server) { + const key = s.email.trim().toLowerCase(); + if (!key || shownKeys.has(key) || src.exclude.has(key)) continue; + shownKeys.add(key); + shown.push({ ...s }); + } + return shown; +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index dc67853d..784dfcb0 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -11,7 +11,7 @@ import { List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, - Users, Tag, Type, Highlighter, Trash2, + Users, Tag, Type, Highlighter, Trash2, Search, } from '../components/icons'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as ImagePicker from 'expo-image-picker'; @@ -63,6 +63,7 @@ import { isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient, } from '../lib/recipients'; import { toUnicodeEmail } from '../lib/idn'; +import { mergeRecipientSuggestions } from '../lib/recipient-suggestions'; import { findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, } from '../lib/reply-identity'; @@ -250,17 +251,21 @@ function initialsOf(name: string, email: string): string { } function SuggestionList({ - suggestions, onPick, onPressIn, + suggestions, onPick, onPressIn, onSearchServer, searchingServer, }: { suggestions: RecipientSuggestion[]; onPick: (s: RecipientSuggestion) => void; onPressIn?: () => void; + /** Search the Sent folder on the server for more (webmail: "Search the server"). */ + onSearchServer?: () => void; + searchingServer?: boolean; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); return ( + {suggestions.map((s, i) => ( ))} + {onSearchServer && ( + { if (!searchingServer) onSearchServer(); }} + disabled={searchingServer} + accessibilityRole="button" + accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }} + style={({ pressed }) => [ + styles.suggestionRow, + suggestions.length > 0 && styles.suggestionSearchRowDivided, + pressed && styles.suggestionRowPressed, + ]} + > + + {searchingServer + ? + : } + + + {searchingServer + ? t('email_composer.autocomplete_searching', 'Searching...') + : t('email_composer.autocomplete_search_server', 'Search the server')} + + + )} + ); } @@ -740,6 +771,22 @@ export default function ComposeScreen({ route, navigation }: Props) { const loadRecentRecipients = useContactsStore((s) => s.loadRecentRecipients); const contactsVersion = useContactsStore((s) => s.contacts); const recentVersion = useContactsStore((s) => s.recentRecipients); + const findContactByEmail = useContactsStore((s) => s.findContactByEmail); + const lookupDirectory = useContactsStore((s) => s.lookupDirectory); + const directoryVersion = useContactsStore((s) => s.directory); + const directoryStatus = useContactsStore((s) => s.directoryStatus); + const fetchDirectory = useContactsStore((s) => s.fetchDirectory); + const searchRecipients = useContactsStore((s) => s.searchRecipients); + // A known Sent folder makes the on-demand server search worth offering. + const canSearchServer = useContactsStore((s) => s.sentMailboxId != null); + const [serverHits, setServerHits] = React.useState<{ query: string; results: RecipientSuggestion[] } | null>(null); + const [searchingServer, setSearchingServer] = React.useState(false); + + // People in the company directory are suggested too (webmail loads the + // directory at sign-in); the store keeps one load per login. + React.useEffect(() => { + if (directoryStatus === 'idle' && !loginMovedFrom(ownerLoginRef.current)) void fetchDirectory(); + }, [directoryStatus, fetchDirectory]); React.useEffect(() => { if (sentMailbox?.id) void loadRecentRecipients(sentMailbox.id); @@ -759,12 +806,32 @@ export default function ComposeScreen({ route, navigation }: Props) { const suggestions = React.useMemo(() => { const q = suggestionQuery.trim(); if (q.length < 1) return []; - return getAutocomplete(q, 16) - .filter((s) => s.group || !alreadySelected.has(s.email.toLowerCase())) - .slice(0, 8); - // contactsVersion/recentVersion re-run the lookup when the store loads. + return mergeRecipientSuggestions({ + local: getAutocomplete(q, 16), + isContact: (s) => !!s.group || !!findContactByEmail(s.email), + directory: lookupDirectory(q, 16), + server: serverHits?.query === q ? serverHits.results : [], + exclude: alreadySelected, + limit: 8, + }); + // contactsVersion/recentVersion/directoryVersion re-run the lookup when the store loads. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [suggestionQuery, alreadySelected, getAutocomplete, contactsVersion, recentVersion]); + }, [suggestionQuery, alreadySelected, getAutocomplete, findContactByEmail, lookupDirectory, serverHits, contactsVersion, recentVersion, directoryVersion]); + + // On demand: search the Sent folder on the server for recipients matching + // what is typed, and add new ones below the suggestions (webmail + // handleServerSearch). Only for the login the message is written in. + const searchServer = async () => { + const q = suggestionQuery.trim(); + if (!q || searchingServer || loginMovedFrom(ownerLoginRef.current)) return; + setSearchingServer(true); + try { + const results = await searchRecipients(q); + if (!loginMovedFrom(ownerLoginRef.current)) setServerHits({ query: q, results }); + } finally { + setSearchingServer(false); + } + }; const setterFor = (field: Field) => field === 'to' ? setToRecipients : field === 'cc' ? setCcRecipients : setBccRecipients; @@ -2685,13 +2752,19 @@ export default function ComposeScreen({ route, navigation }: Props) { textContentType="none" /> - {activeField === field && suggestions.length > 0 && ( + {activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && ( { isPickingSuggestion.current = true; }} + onSearchServer={canSearchServer ? () => { + // No pick: a later blur turns the typed text into a chip again. + setTimeout(() => { isPickingSuggestion.current = false; }, 250); + void searchServer(); + } : undefined} + searchingServer={searchingServer} /> )} {field === 'to' && !ccVisible && ( @@ -3554,6 +3627,10 @@ function makeStyles(c: ThemePalette) { gap: spacing.sm, }, suggestionRowPressed: { backgroundColor: c.surfaceHover }, + suggestionScroll: { maxHeight: 320 }, + suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight }, + suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 }, suggestionAvatar: { width: 32, height: 32, borderRadius: 16, From 98b962a8d4cd2bca77e896a1464917e0c91f696f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:29:38 +0100 Subject: [PATCH 7/9] fix: name the composer, quick reply, folder and template search fields for screen readers --- src/components/SidebarDrawer.tsx | 1 + src/components/TemplateSheet.tsx | 1 + .../__tests__/field-names-mail.test.ts | 56 +++++++++++++++++++ src/components/email/QuickReplyBox.tsx | 1 + src/screens/ComposeScreen.tsx | 8 +++ 5 files changed, 67 insertions(+) create mode 100644 src/components/__tests__/field-names-mail.test.ts diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 7b123aba..66dea0bd 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -291,6 +291,7 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose } {title} {message ? {message} : null} {total > 4 && ( ') return i; + } + return source.length; +} + +/** `file:line` of every TextInput element without an accessibilityLabel. */ +function unnamedFields(file: string): string[] { + const source = readFileSync(join(SRC, file), 'utf8'); + const out: string[] = []; + // ``. + for (const m of source.matchAll(/ name.endsWith('.tsx')) + .map((name) => `components/compose/${name}`); + +const FILES = [ + 'screens/ComposeScreen.tsx', + 'components/email/QuickReplyBox.tsx', + 'components/SidebarDrawer.tsx', + 'components/TemplateSheet.tsx', + ...COMPOSE, +]; + +describe('field names in mail and the composer', () => { + it('every field has a name', () => { + expect(FILES.flatMap(unnamedFields)).toEqual([]); + }); + + it('finds the composer fields at all', () => { + const source = readFileSync(join(SRC, 'screens/ComposeScreen.tsx'), 'utf8'); + expect([...source.matchAll(/ setPlaceholderPrompt((p) => (p ? { ...p, values: { ...p.values, [name]: v } } : p))} @@ -3392,6 +3397,7 @@ export default function ComposeScreen({ route, navigation }: Props) { <> {t('identities.sub_address.popover_title', 'Add Sub-Address Tag')} {t('email_composer.from_override.toggle_tooltip', 'Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.')} Date: Sat, 31 Oct 2026 05:31:34 +0100 Subject: [PATCH 8/9] refactor: draw the booking and parcel card icons from the generated icon set --- scripts/generate-icons.mjs | 12 ++++++++++ .../booking-calendar-actions.tsx | 3 +-- .../email/structured-mail/booking-cards.tsx | 3 +-- src/components/email/structured-mail/icons.ts | 19 --------------- .../email/structured-mail/shipment-card.tsx | 3 +-- src/components/icons/index.ts | 24 +++++++++++++++++++ 6 files changed, 39 insertions(+), 25 deletions(-) delete mode 100644 src/components/email/structured-mail/icons.ts diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index a66acec7..e6d13c7c 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -37,6 +37,7 @@ const MAP = { ArrowUpDown: 'arrows-sort', ArrowUpNarrowWide: 'sort-ascending', Ban: 'ban', + Bed: 'bed', Bell: 'bell', BellOff: 'bell-off', BellRing: 'bell-ringing', @@ -46,11 +47,15 @@ const MAP = { BookUser: 'address-book', Bug: 'bug', Building: 'building', + Bus: 'bus', Cake: 'cake', Calendar: 'calendar', + CalendarCheck: 'calendar-check', CalendarDays: 'calendar-month', CalendarPlus: 'calendar-plus', + CalendarX: 'calendar-x', Camera: 'camera', + Car: 'car', Check: 'check', CheckCheck: 'checks', CheckCircle: 'circle-check', @@ -139,6 +144,7 @@ const MAP = { MoreHorizontal: 'dots', MoreVertical: 'dots-vertical', MoveVertical: 'arrows-vertical', + Package: 'package', Palette: 'palette', Palmtree: 'beach', PanelLeftClose: 'layout-sidebar-left-collapse', @@ -150,6 +156,7 @@ const MAP = { Phone: 'phone', Pin: 'pinned', PinOff: 'pinned-off', + Plane: 'plane', Plus: 'plus', Puzzle: 'puzzle', QrCode: 'qrcode', @@ -160,6 +167,7 @@ const MAP = { Repeat: 'repeat', Reply: 'arrow-back-up', ReplyAll: 'arrow-back-up-double', + Restaurant: 'tools-kitchen-2', RotateCcw: 'rotate', Rows2: 'layout-rows', Rss: 'rss', @@ -175,6 +183,7 @@ const MAP = { ShieldAlert: 'shield-exclamation', ShieldCheck: 'shield-check', ShieldQuestion: 'shield-question', + ShoppingBag: 'shopping-bag', Shuffle: 'arrows-shuffle', Smartphone: 'device-mobile', Square: 'square', @@ -188,8 +197,11 @@ const MAP = { Tag: 'tag', Tags: 'tags', Terminal: 'terminal', + Ticket: 'ticket', + Train: 'train', Trash: 'trash', Trash2: 'trash', + Truck: 'truck', Type: 'typography', Underline: 'underline', Undo2: 'arrow-back', diff --git a/src/components/email/structured-mail/booking-calendar-actions.tsx b/src/components/email/structured-mail/booking-calendar-actions.tsx index 4ff75ce5..d2d69a0d 100644 --- a/src/components/email/structured-mail/booking-calendar-actions.tsx +++ b/src/components/email/structured-mail/booking-calendar-actions.tsx @@ -13,8 +13,7 @@ import type { RootStackParamList } from '../../../navigation/types'; import { useLocaleStore } from '../../../stores/locale-store'; import { useColors } from '../../../theme/colors'; import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens'; -import { ArrowRight, CalendarDays, CalendarPlus, ChevronDown } from '../../icons'; -import { CalendarCheck, CalendarX } from './icons'; +import { ArrowRight, CalendarCheck, CalendarDays, CalendarPlus, CalendarX, ChevronDown } from '../../icons'; import { useBookingCalendar } from './use-booking-calendar'; type Nav = NativeStackNavigationProp; diff --git a/src/components/email/structured-mail/booking-cards.tsx b/src/components/email/structured-mail/booking-cards.tsx index 992a7ea2..9e62d5ba 100644 --- a/src/components/email/structured-mail/booking-cards.tsx +++ b/src/components/email/structured-mail/booking-cards.tsx @@ -4,7 +4,7 @@ // points the reading direction, where the webmail draws arrow icons. import React from 'react'; import { I18nManager, Text, View } from 'react-native'; -import { ArrowRight, Clock, MapPin, Phone, Users } from '../../icons'; +import { ArrowRight, Bed, Bus, Car, Clock, MapPin, Phone, Plane, Restaurant, Ticket, Train, Users } from '../../icons'; import { formatAddress, formatMomentDate, @@ -46,7 +46,6 @@ import { useCardStyles, } from './card-parts'; import { CopyValueChip } from './copy-value-chip'; -import { Bed, Bus, Car, Plane, Restaurant, Ticket, Train } from './icons'; export interface BookingCardProps { booking: B; diff --git a/src/components/email/structured-mail/icons.ts b/src/components/email/structured-mail/icons.ts deleted file mode 100644 index 0662dcd1..00000000 --- a/src/components/email/structured-mail/icons.ts +++ /dev/null @@ -1,19 +0,0 @@ -// The card glyphs the app's generated icon set (src/components/icons, from -// scripts/generate-icons.mjs) doesn't ship yet, from the same Tabler outline -// data (MIT) under the webmail's names. Once they are added to MAP in that -// script, import them from components/icons and delete this file. - -import { makeIcon } from '../../icons/icon'; - -export const Bed = makeIcon('Bed', ["M5 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M22 17v-3h-20", "M2 8v9", "M12 14h10v-2a3 3 0 0 0 -3 -3h-7v5"]); -export const Bus = makeIcon('Bus', ["M4 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M16 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8", "M16 5l1.5 7l4.5 0", "M2 10l15 0", "M7 5l0 5", "M12 5l0 5"]); -export const CalendarCheck = makeIcon('CalendarCheck', ["M11.5 21h-5.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6", "M16 3v4", "M8 3v4", "M4 11h16", "M15 19l2 2l4 -4"]); -export const CalendarX = makeIcon('CalendarX', ["M13 21h-7a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6.5", "M16 3v4", "M8 3v4", "M4 11h16", "M22 22l-5 -5", "M17 22l5 -5"]); -export const Car = makeIcon('Car', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-6l2 -5h9l4 5h1a2 2 0 0 1 2 2v4h-2m-4 0h-6m-6 -6h15m-6 0v-5"]); -export const Package = makeIcon('Package', ["M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5", "M12 12l8 -4.5", "M12 12l0 9", "M12 12l-8 -4.5", "M16 5.25l-8 4.5"]); -export const Plane = makeIcon('Plane', ["M16 10h4a2 2 0 0 1 0 4h-4l-4 7h-3l2 -7h-4l-2 2h-3l2 -4l-2 -4h3l2 2h4l-2 -7h3l4 7"]); -export const Restaurant = makeIcon('Restaurant', ["M19 3v12h-5c-.023 -3.681 .184 -7.406 5 -12m0 12v6h-1v-3m-10 -14v17m-3 -17v3a3 3 0 1 0 6 0v-3"]); -export const ShoppingBag = makeIcon('ShoppingBag', ["M6.331 8h11.339a2 2 0 0 1 1.977 2.304l-1.255 8.152a3 3 0 0 1 -2.966 2.544h-6.852a3 3 0 0 1 -2.965 -2.544l-1.255 -8.152a2 2 0 0 1 1.977 -2.304", "M9 11v-5a3 3 0 0 1 6 0v5"]); -export const Ticket = makeIcon('Ticket', ["M15 5l0 2", "M15 11l0 2", "M15 17l0 2", "M5 5h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-3a2 2 0 0 0 0 -4v-3a2 2 0 0 1 2 -2"]); -export const Train = makeIcon('Train', ["M21 13c0 -3.87 -3.37 -7 -10 -7h-8", "M3 15h16a2 2 0 0 0 2 -2", "M3 6v5h17.5", "M3 11v4", "M8 11v-5", "M13 11v-4.5", "M3 19h18"]); -export const Truck = makeIcon('Truck', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-11a1 1 0 0 1 1 -1h9v12m-4 0h6m4 0h2v-6h-8m0 -5h5l3 5"]); diff --git a/src/components/email/structured-mail/shipment-card.tsx b/src/components/email/structured-mail/shipment-card.tsx index 0a8671a9..c1949d00 100644 --- a/src/components/email/structured-mail/shipment-card.tsx +++ b/src/components/email/structured-mail/shipment-card.tsx @@ -2,13 +2,12 @@ // b0782af2d (same rows, React Native views). import React from 'react'; import { Image, Text, View } from 'react-native'; -import { Clock, ExternalLink } from '../../icons'; +import { Clock, ExternalLink, Package, ShoppingBag, Truck } from '../../icons'; import { formatMomentDate, formatMoney, isWebLink, shipmentItems, summarizeItems } from '../../../lib/structured-mail/format'; import type { Item, Link, OrderStatus, Parcel, Shipment } from '../../../lib/structured-mail/types'; import { useLocaleStore } from '../../../stores/locale-store'; import { CardLink, CardShell, MetaRow, Pill, useCardFormat, useCardStyles } from './card-parts'; import { CopyValueChip } from './copy-value-chip'; -import { Package, ShoppingBag, Truck } from './icons'; const STATUS_TONE: Record = { payment_due: 'warning', diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index 6d52fb2c..ed6a9c9f 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -29,6 +29,7 @@ const G = { 'arrows-vertical': ["M8 7l4 -4l4 4","M8 17l4 4l4 -4","M12 3l0 18"], 'ban': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M5.7 5.7l12.6 12.6"], 'beach': ["M17.553 16.75a7.5 7.5 0 0 0 -10.606 0","M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196","M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196","M15 9l-3 5.196","M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"], + 'bed': ["M5 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M22 17v-3h-20","M2 8v9","M12 14h10v-2a3 3 0 0 0 -3 -3h-7v5"], 'bell': ["M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1"], 'bell-off': ["M9.346 5.353c.21 -.129 .428 -.246 .654 -.353a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3m-1 3h-13a4 4 0 0 0 2 -3v-3a6.996 6.996 0 0 1 1.273 -3.707","M9 17v1a3 3 0 0 0 6 0v-1","M3 3l18 18"], 'bell-ringing': ["M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1","M21 6.727a11.05 11.05 0 0 0 -2.794 -3.727","M3 6.727a11.05 11.05 0 0 1 2.792 -3.727"], @@ -37,11 +38,15 @@ const G = { 'book-2': ["M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12","M19 16h-12a2 2 0 0 0 -2 2","M9 8h6"], 'bug': ["M9 9v-1a3 3 0 0 1 6 0v1","M8 9h8a6 6 0 0 1 1 3v3a5 5 0 0 1 -10 0v-3a6 6 0 0 1 1 -3","M3 13l4 0","M17 13l4 0","M12 20l0 -6","M4 19l3.35 -2","M20 19l-3.35 -2","M4 7l3.75 2.4","M20 7l-3.75 2.4"], 'building': ["M3 21l18 0","M9 8l1 0","M9 12l1 0","M9 16l1 0","M14 8l1 0","M14 12l1 0","M14 16l1 0","M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16"], + 'bus': ["M4 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M16 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8","M16 5l1.5 7l4.5 0","M2 10l15 0","M7 5l0 5","M12 5l0 5"], 'cake': ["M3 20h18v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8","M3 14.803c.312 .135 .654 .204 1 .197a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1c.35 .007 .692 -.062 1 -.197","M12 4l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737"], 'calendar': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M11 15h1","M12 15v3"], + 'calendar-check': ["M11.5 21h-5.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6","M16 3v4","M8 3v4","M4 11h16","M15 19l2 2l4 -4"], 'calendar-month': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M8 14v4","M12 14v4","M16 14v4"], 'calendar-plus': ["M12.5 21h-6.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v5","M16 3v4","M8 3v4","M4 11h16","M16 19h6","M19 16v6"], + 'calendar-x': ["M13 21h-7a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6.5","M16 3v4","M8 3v4","M4 11h16","M22 22l-5 -5","M17 22l5 -5"], 'camera': ["M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2","M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"], + 'car': ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M5 17h-2v-6l2 -5h9l4 5h1a2 2 0 0 1 2 2v4h-2m-4 0h-6m-6 -6h15m-6 0v-5"], 'check': ["M5 12l5 5l10 -10"], 'checks': ["M7 12l5 5l10 -10","M2 12l5 5m5 -5l5 -5"], 'chevron-down': ["M6 9l6 6l6 -6"], @@ -121,6 +126,7 @@ const G = { 'minus': ["M5 12l14 0"], 'moon': ["M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454l0 .008"], 'note': ["M13 20l7 -7","M13 20v-6a1 1 0 0 1 1 -1h6v-7a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7"], + 'package': ["M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5","M12 12l8 -4.5","M12 12l0 9","M12 12l-8 -4.5","M16 5.25l-8 4.5"], 'palette': ["M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25","M7.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M15.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"], 'paperclip': ["M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"], 'pencil': ["M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4","M13.5 6.5l4 4"], @@ -128,6 +134,7 @@ const G = { 'photo': ["M15 8h.01","M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12","M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5","M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"], 'pinned': ["M9 4v6l-2 4v2h10v-2l-2 -4v-6","M12 16l0 5","M8 4l8 0"], 'pinned-off': ["M9 4v1","M9 9v1l-2 4v2h9","M16 12l-1 -2v-6","M12 16v5","M8 4h8","M3 3l18 18"], + 'plane': ["M16 10h4a2 2 0 0 1 0 4h-4l-4 7h-3l2 -7h-4l-2 2h-3l2 -4l-2 -4h3l2 2h4l-2 -7h3l4 7"], 'plus': ["M12 5l0 14","M5 12l14 0"], 'puzzle': ["M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1"], 'qrcode': ["M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 17l0 .01","M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 7l0 .01","M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M17 7l0 .01","M14 14l3 0","M20 14l0 .01","M14 14l0 3","M14 20l3 0","M17 17l3 0","M20 17l0 3"], @@ -147,6 +154,7 @@ const G = { 'shield-check': ["M11.46 20.846a12 12 0 0 1 -7.96 -14.846a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 -.09 7.06","M15 19l2 2l4 -4"], 'shield-exclamation': ["M15.04 19.745c-.942 .551 -1.964 .976 -3.04 1.255a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 .195 6.015","M19 16v3","M19 22v.01"], 'shield-question': ["M15.065 19.732c-.95 .557 -1.98 .986 -3.065 1.268a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3c.51 1.738 .617 3.55 .333 5.303","M19 22v.01","M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"], + 'shopping-bag': ["M6.331 8h11.339a2 2 0 0 1 1.977 2.304l-1.255 8.152a3 3 0 0 1 -2.966 2.544h-6.852a3 3 0 0 1 -2.965 -2.544l-1.255 -8.152a2 2 0 0 1 1.977 -2.304","M9 11v-5a3 3 0 0 1 6 0v5"], 'sort-ascending': ["M4 6l7 0","M4 12l7 0","M4 18l9 0","M15 9l3 -3l3 3","M18 6l0 12"], 'sort-descending': ["M4 6l9 0","M4 12l7 0","M4 18l7 0","M15 15l3 3l3 -3","M18 6l0 12"], 'square': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14"], @@ -160,7 +168,11 @@ const G = { 'tags': ["M3 8v4.172a2 2 0 0 0 .586 1.414l5.71 5.71a2.41 2.41 0 0 0 3.408 0l3.592 -3.592a2.41 2.41 0 0 0 0 -3.408l-5.71 -5.71a2 2 0 0 0 -1.414 -.586h-4.172a2 2 0 0 0 -2 2","M18 19l1.592 -1.592a4.82 4.82 0 0 0 0 -6.816l-4.592 -4.592","M7 10h-.01"], 'template': ["M4 5a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1l0 -2","M4 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -6","M14 12l6 0","M14 16l6 0","M14 20l6 0"], 'terminal': ["M5 7l5 5l-5 5","M12 19l7 0"], + 'ticket': ["M15 5l0 2","M15 11l0 2","M15 17l0 2","M5 5h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-3a2 2 0 0 0 0 -4v-3a2 2 0 0 1 2 -2"], + 'tools-kitchen-2': ["M19 3v12h-5c-.023 -3.681 .184 -7.406 5 -12m0 12v6h-1v-3m-10 -14v17m-3 -17v3a3 3 0 1 0 6 0v-3"], + 'train': ["M21 13c0 -3.87 -3.37 -7 -10 -7h-8","M3 15h16a2 2 0 0 0 2 -2","M3 6v5h17.5","M3 11v4","M8 11v-5","M13 11v-4.5","M3 19h18"], 'trash': ["M4 7l16 0","M10 11l0 6","M14 11l0 6","M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12","M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"], + 'truck': ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M5 17h-2v-11a1 1 0 0 1 1 -1h9v12m-4 0h6m4 0h2v-6h-8m0 -5h5l3 5"], 'typography': ["M4 20l3 0","M14 20l7 0","M6.9 15l6.9 0","M10.2 6.3l5.8 13.7","M5 20l6 -16l2 0l7 16"], 'underline': ["M7 5v5a5 5 0 0 0 10 0v-5","M5 19h14"], 'unlink': ["M17 22v-2","M9 15l6 -6","M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464","M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463","M20 17h2","M2 7h2","M7 2v2"], @@ -193,6 +205,7 @@ export const ArrowUp = makeIcon('ArrowUp', G['arrow-up']); export const ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']); export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']); export const Ban = makeIcon('Ban', G['ban']); +export const Bed = makeIcon('Bed', G['bed']); export const Bell = makeIcon('Bell', G['bell']); export const BellOff = makeIcon('BellOff', G['bell-off']); export const BellRing = makeIcon('BellRing', G['bell-ringing']); @@ -202,11 +215,15 @@ export const BookOpen = makeIcon('BookOpen', G['book']); export const BookUser = makeIcon('BookUser', G['address-book']); export const Bug = makeIcon('Bug', G['bug']); export const Building = makeIcon('Building', G['building']); +export const Bus = makeIcon('Bus', G['bus']); export const Cake = makeIcon('Cake', G['cake']); export const Calendar = makeIcon('Calendar', G['calendar']); +export const CalendarCheck = makeIcon('CalendarCheck', G['calendar-check']); export const CalendarDays = makeIcon('CalendarDays', G['calendar-month']); export const CalendarPlus = makeIcon('CalendarPlus', G['calendar-plus']); +export const CalendarX = makeIcon('CalendarX', G['calendar-x']); export const Camera = makeIcon('Camera', G['camera']); +export const Car = makeIcon('Car', G['car']); export const Check = makeIcon('Check', G['check']); export const CheckCheck = makeIcon('CheckCheck', G['checks']); export const CheckCircle = makeIcon('CheckCircle', G['circle-check']); @@ -295,6 +312,7 @@ export const Moon = makeIcon('Moon', G['moon']); export const MoreHorizontal = makeIcon('MoreHorizontal', G['dots']); export const MoreVertical = makeIcon('MoreVertical', G['dots-vertical']); export const MoveVertical = makeIcon('MoveVertical', G['arrows-vertical']); +export const Package = makeIcon('Package', G['package']); export const Palette = makeIcon('Palette', G['palette']); export const Palmtree = makeIcon('Palmtree', G['beach']); export const PanelLeftClose = makeIcon('PanelLeftClose', G['layout-sidebar-left-collapse']); @@ -306,6 +324,7 @@ export const Pencil = makeIcon('Pencil', G['pencil']); export const Phone = makeIcon('Phone', G['phone']); export const Pin = makeIcon('Pin', G['pinned']); export const PinOff = makeIcon('PinOff', G['pinned-off']); +export const Plane = makeIcon('Plane', G['plane']); export const Plus = makeIcon('Plus', G['plus']); export const Puzzle = makeIcon('Puzzle', G['puzzle']); export const QrCode = makeIcon('QrCode', G['qrcode']); @@ -316,6 +335,7 @@ export const RemoveFormatting = makeIcon('RemoveFormatting', G['clear-formatting export const Repeat = makeIcon('Repeat', G['repeat']); export const Reply = makeIcon('Reply', G['arrow-back-up']); export const ReplyAll = makeIcon('ReplyAll', G['arrow-back-up-double']); +export const Restaurant = makeIcon('Restaurant', G['tools-kitchen-2']); export const RotateCcw = makeIcon('RotateCcw', G['rotate']); export const Rows2 = makeIcon('Rows2', G['layout-rows']); export const Rss = makeIcon('Rss', G['rss']); @@ -331,6 +351,7 @@ export const Shield = makeIcon('Shield', G['shield']); export const ShieldAlert = makeIcon('ShieldAlert', G['shield-exclamation']); export const ShieldCheck = makeIcon('ShieldCheck', G['shield-check']); export const ShieldQuestion = makeIcon('ShieldQuestion', G['shield-question']); +export const ShoppingBag = makeIcon('ShoppingBag', G['shopping-bag']); export const Shuffle = makeIcon('Shuffle', G['arrows-shuffle']); export const Smartphone = makeIcon('Smartphone', G['device-mobile']); export const Square = makeIcon('Square', G['square']); @@ -344,8 +365,11 @@ export const Table = makeIcon('Table', G['table']); export const Tag = makeIcon('Tag', G['tag']); export const Tags = makeIcon('Tags', G['tags']); export const Terminal = makeIcon('Terminal', G['terminal']); +export const Ticket = makeIcon('Ticket', G['ticket']); +export const Train = makeIcon('Train', G['train']); export const Trash = makeIcon('Trash', G['trash']); export const Trash2 = makeIcon('Trash2', G['trash']); +export const Truck = makeIcon('Truck', G['truck']); export const Type = makeIcon('Type', G['typography']); export const Underline = makeIcon('Underline', G['underline']); export const Undo2 = makeIcon('Undo2', G['arrow-back']); From beae63e144434579bff93fb367fa041a4f651feb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:33:10 +0100 Subject: [PATCH 9/9] docs: tick the rows the follow-up pass fixed --- docs/parity/06-contacts.md | 2 +- docs/parity/10-webmail-1.10-to-1.18.md | 4 ++-- docs/parity/11-webmail-1.19.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/parity/06-contacts.md b/docs/parity/06-contacts.md index 6e3409b0..7b3064eb 100644 --- a/docs/parity/06-contacts.md +++ b/docs/parity/06-contacts.md @@ -189,7 +189,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det ### Composer autocomplete -- [ ] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing` — event participants get groups and recent recipients in e988dc29; not done: on-demand server search and directory principals in the composer (compose agent's side) +- [x] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing` — fixed in a7a37573 (the composer's on-demand Sent search and directory people; event participants got groups and recent recipients in e988dc29) - store-side done in 26a34d5 (`getAutocomplete` with group entries + recent recipients, `loadRecentRecipients`/`searchRecipients` fed by `src/api/recent-recipients.ts`, `getGroupRecipients`, #672 normalisation); the composer uses `getAutocomplete` and the recent Sent recipients since 5b72c4d. Still open: the on-demand server search (`searchRecipients` has no caller), directory principals, and the calendar's `ParticipantInput`, which still offers contacts only. - What WEB does: `getAutocomplete` merges contacts, group entries (as one chip with `memberCount`), RFC 9670 directory principals and recent Sent recipients, deduped and sanitised (#672) (`stores/contact-store.ts:622-707, 23-49`; `loadRecentRecipients` `:1118-1142` fed from `mail-app.tsx:423`; `searchRecipients` `:1144-1154` used by `components/email/email-composer.tsx:1175-1212`; group chip insertion `:1218-1233`; changelogs 1.6.x "Recipient autocomplete from Sent, with on-demand server search", 1.7.x group chips). - What RN does: suggestions come only from `individuals` matched with `matchesContactSearch` (`src/screens/ComposeScreen.tsx:367-392`); groups are explicitly filtered out and nothing else is consulted. `ParticipantInput` (calendar) likewise flattens contacts only (`src/components/calendar/ParticipantInput.tsx:34-51`). diff --git a/docs/parity/10-webmail-1.10-to-1.18.md b/docs/parity/10-webmail-1.10-to-1.18.md index 024775c3..3e066545 100644 --- a/docs/parity/10-webmail-1.10-to-1.18.md +++ b/docs/parity/10-webmail-1.10-to-1.18.md @@ -70,7 +70,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - What RN does: clearAllFeatureStores (`src/stores/auth-store.ts:126-134`) never clears `src/stores/search-history-store.ts:44`. - Fix hint: clear it, and check the other persisted stores in the same pass. -- [x] **Internationalized domains** — `P3` — `missing` — fixed in f5875f87 (sign-in, discovery, account rows; the composer's recipient check goes with the compose branch) +- [x] **Internationalized domains** — `P3` — `missing` — fixed in f5875f87 (sign-in, discovery, account rows) and 4932764a (the composer takes recipients on Unicode domains and sends them in ASCII form; the From identity and a sender on one of your own domains show as written in the composer and the reader; unit tests only, the test servers have no IDN domain); left: the sender in the mail list rows (EmailListScreen, UnifiedInboxScreen) still shows the xn-- form on your own domains - What WEB does: `lib/idn.ts:25` (#1100). What RN does: ASCII-only checks (`src/lib/recipients.ts:31`, `src/lib/server-discovery.ts:78`). - Fix hint: toASCII on the wire, Unicode on screen. @@ -394,7 +394,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [x] **Create rules from a message** — `P2` — `missing` — fixed in d546d509, 7b7236f0 - What WEB does: `components/email/rules-menu.tsx`, `lib/filters/quick-rules.ts:187`. What RN does: `src/components/email/ActionSheet.tsx`, `src/components/filters/FilterRuleModal.tsx`. -- [ ] **Forward limit (#1155, #1208); vacation length warning; run a rule on a folder (#1166); Sieve editor loses typed text (#1162); size value raw into Sieve; undo after deleting tags/rules/trusted senders** — `P3` — `missing`/`bug` — forward limit and vacation length fixed in dcbfc907, Sieve editor text in 6c3046c8, size value in 2167a9d7 (before this pass); undo after deleting rules: store side in b95f2c29, FilterSettings (platform agent's file) still has to call deleteRuleNow; not done: undo after deleting tags and trusted senders (KeywordSettings and the trusted-senders list are not the calendar agent's files); run a rule on a folder: needs server-side run, its own feature +- [ ] **Forward limit (#1155, #1208); vacation length warning; run a rule on a folder (#1166); Sieve editor loses typed text (#1162); size value raw into Sieve; undo after deleting tags/rules/trusted senders** — `P3` — `missing`/`bug` — forward limit and vacation length fixed in dcbfc907, Sieve editor text in 6c3046c8, size value in 2167a9d7 (before this pass); undo after deleting rules (store side b95f2c29), tags and trusted senders, and the forward limit notes in the rule list, in 8ac19971; not done: run a rule on a folder, which needs a server-side run and is its own feature - What WEB does: `lib/filters/forward-limit.ts:16`, `components/settings/vacation-settings.tsx:253`, `lib/filters/quick-rule-flow.ts:221`, `lib/sieve/generator.ts:63`. What RN does: `src/components/filters/FilterRuleModal.tsx:63`, `:413`, `src/components/settings/VacationSettings.tsx:106`, `src/components/filters/SieveEditorSheet.tsx:35-41`, `src/lib/sieve/generator.ts:69-74`, `src/components/settings/KeywordSettings.tsx:50-56`. ### Push, settings and core diff --git a/docs/parity/11-webmail-1.19.md b/docs/parity/11-webmail-1.19.md index eb86bff8..381eac94 100644 --- a/docs/parity/11-webmail-1.19.md +++ b/docs/parity/11-webmail-1.19.md @@ -135,7 +135,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; - What RN does: no page (`src/screens/SettingsScreen.tsx:150-174`); text size 0.92/1/1.12 (`src/theme/dynamic.ts:14-18`). Text follows the system font scale; reduce motion follows the system (`src/theme/dynamic.ts:88-120`). - Fix hint: a page that groups the existing switches, widens the text size range and takes the presets that make sense on a phone. -- [x] **Form fields have no name for screen readers** — `P2` — `bug` — fixed in ff5a8c91 (settings and sign-in; contact form and event editor are listed for their branches) +- [x] **Form fields have no name for screen readers** — `P2` — `bug` — fixed in ff5a8c91 (settings and sign-in), 98b962a8 (composer, quick reply, folder name prompt, template search); contact form, event editor, Files, the mail list search and the auto-reply editor are listed for their branches - What WEB does: every field has a label tied to it (`components/ui/field.tsx:42`; ef13556a0, 0e2a05e0e, dcc21acd0, 4e2affe40, f73643bbb). - What RN does: the shared Input draws its label as separate Text (`src/components/Input.tsx:40-55`); the Security password and code fields have no label at all (`src/components/settings/AccountSecuritySettings.tsx:181`, `:191`, `:198`); contact fields rely on placeholders (`src/screens/ContactFormScreen.tsx:823-834`); the reminder field's only name is "30" (`src/components/calendar/EventModal.tsx:772`, `:817`). - Fix hint: Input passes its label as accessibilityLabel; every bare TextInput gets one. @@ -185,7 +185,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; - What RN does: TotpRequiredError sets a notice (`src/screens/LoginScreen.tsx:389-391`) shown in the error tone (`src/screens/login/PasswordStep.tsx:151`, `src/screens/login/LoginNotice.tsx:15-24`). - Fix hint: `tone="info"` for the TOTP step. -- [x] **Two accounts with the same name look alike in the switcher** — `P3` — `bug` — fixed in 505fbc59 (Settings > Account and the shared accountRowText; the switcher row in SidebarDrawer goes with the reader branch; the port waits for question 9) +- [x] **Two accounts with the same name look alike in the switcher** — `P3` — `bug` — fixed in 505fbc59 (Settings > Account and the shared accountRowText) and 97173d4a (the switcher rows in SidebarDrawer); the port waits for question 9 - What WEB does: the address becomes the title when another account shares the name (`lib/account-utils.ts:159`; 3bb9f7df8). - What RN does: name as title, address below (`src/components/SidebarDrawer.tsx:884-891`, `src/components/settings/AccountSettings.tsx:186`). Report question 9 (port in the label) is the same area. - Fix hint: port accountRowText.