diff --git a/docs/native-sync-2026-11.md b/docs/native-sync-2026-11.md index 6061659a..bc72618d 100644 --- a/docs/native-sync-2026-11.md +++ b/docs/native-sync-2026-11.md @@ -137,7 +137,7 @@ current lines is in "Webmail bugs" at the end of this file. - [x] **U-133 no way out of a hung "Signing in"** — `P2` — `missing` — src/screens/LoginScreen.tsx:218 swallows Back while busy, SigningInStep has no cancel; abortable sign-in, M — fixed in 55d008a2 - [ ] **U-069 recipient suggestions hidden in phone landscape** — `P2` — `missing` — needs a full-screen picker, M (feature) - [x] **U-045 German Prev/Next read "Zurück"/"Weiter"** — `P3` — `bug` — override in locales/rn/de.json — fixed in 25f9b810 -- [ ] **Plain-text mail dark background fixed at #0a0a0a** — `P3` — `partial` — src/lib/email-html.ts:963 ignores the theme +- [x] **Plain-text mail dark background fixed at #0a0a0a** — `P3` — `partial` — src/lib/email-html.ts:963 ignores the theme — fixed in 43973608 - [ ] **U-017 system alert vs Dialog, U-136 empty all-day strip, U-124 language order** — product calls, kept deferred unless you say otherwise - U-016 scrim token: won't fix (no visible change). U-018 font scale resets navigation: React Native limitation. U-131: done (776447dd). @@ -151,7 +151,7 @@ current lines is in "Webmail bugs" at the end of this file. ### GitHub (bulwarkmail/native) - Fixed on main, unreleased (close after 0.1.87 ships): #80, #82, #83, #84, #86, #87, #89. #81 is fixed in code (01983a38), unchecked on iOS. -- [ ] **#91 multi-select without long press** — `P3` — `missing` — EmailListScreen.tsx:190-218; tap on the avatar toggles selection +- [x] **#91 multi-select without long press** — `P3` — `missing` — EmailListScreen.tsx:190-218; tap on the avatar toggles selection — fixed in 6715df7f - [x] **#90 custom calendar colours** — `P3` — `missing` — CalendarEditSheet.tsx:222 offers the palette only — fixed in d3f6d6ae - [ ] **#85 keep the shade open on Delete** — question 1 - [ ] **#58 PGP / S/MIME** — `P3` — L, not in this run 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 a6ea9519..024775c3 100644 --- a/docs/parity/10-webmail-1.10-to-1.18.md +++ b/docs/parity/10-webmail-1.10-to-1.18.md @@ -43,7 +43,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - What RN does: reads it from the fragment (`src/lib/oauth.ts:247`); any app can claim a custom scheme. - Fix hint: hand back a one-time code and redeem it like QR pairing (`src/lib/oauth.ts:435`, `/api/auth/pair/redeem`). Needs the webmail side first. -- [ ] **Sender favicons are fetched from icons.duckduckgo.com on the device** — `P3` — `partial` (privacy) +- [x] **Sender favicons are fetched from icons.duckduckgo.com on the device** — `P3` — `partial` (privacy) — fixed in 8e10e73e (off by default; the push background task keeps its own default, see the reader report) - What WEB does: proxies favicons on the server (`app/api/favicon/route.ts:484`). - What RN does: fetches directly, on by default (`src/lib/avatar-utils.ts:149`, `src/stores/settings-store.ts:312`), leaking sender domains and the device IP. - Fix hint: default off, or use the paired webmail's proxy. @@ -116,31 +116,31 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - What WEB does: creates Archive and reuses year/month folders (`stores/email-store.ts:1332`, `:2561`). - What RN does: returns silently (`src/stores/email-store.ts:1594`, `:1729`) or throws (`src/api/unified-inbox.ts:546`). -- [ ] **A server's own "all" mailbox is included in All mail/Unread (#1241)** — `P3` — `bug` +- [x] **A server's own "all" mailbox is included in All mail/Unread (#1241)** — `P3` — `bug` — fixed in 43bb5e47 - What WEB does: role `all` excluded (`lib/jmap/types.ts:1158`). What RN does: `src/api/unified-inbox.ts:34`, `:354`. -- [ ] **Tag views and counts include Trash and Spam (#1156)** — `P3` — `bug` +- [x] **Tag views and counts include Trash and Spam (#1156)** — `P3` — `bug` — fixed in eb6bf6d3 - What WEB does: `lib/unified-mailbox.ts:104`, `:577`. What RN does: `src/api/tag-counts.ts:37-48`, `src/stores/email-store.ts:455`. - [x] **User folders named like system folders get system icons (#1235, native #80)** — `P3` — `bug` — fixed in 96c7c045 - What WEB does: icons by role only (`components/layout/sidebar.tsx:168`). What RN does: name matching fallback (`src/components/SidebarDrawer.tsx:92`). -- [ ] **Sort by "Date sent" falls back to received date in merged lists (#1219)** — `P3` — `bug` +- [x] **Sort by "Date sent" falls back to received date in merged lists (#1219)** — `P3` — `bug` — fixed in 04a962a6 - What WEB does: lists fetch sentAt (`lib/jmap/client.ts:443`). What RN does: EMAIL_LIST_PROPERTIES lacks it (`src/api/email.ts:22`; comparator `src/lib/message-list-order.ts:225`). -- [ ] **Previews start with a style sheet (#1136)** — `P3` — `bug` +- [x] **Previews start with a style sheet (#1136)** — `P3` — `bug` — fixed in 219aef04 (list, unified list, conversation cards; notifications: platform) - What WEB does: cleanPreview (`lib/utils.ts:320`, `:363`). What RN does: `src/screens/EmailListScreen.tsx:270`, `src/lib/push-background-task.ts:513`. -- [ ] **Paperclip on messages with only inline images** — `P3` — `bug` +- [x] **Paperclip on messages with only inline images** — `P3` — `bug` — fixed in 4470a4ba (folder list) - What WEB does: `components/email/attachment-chips.tsx:165`. What RN does: `src/screens/EmailListScreen.tsx:237`, `src/screens/UnifiedInboxScreen.tsx:358` use hasAttachment. -- [ ] **List row grows when its attachment chips arrive** — `P3` — `bug` +- [x] **List row grows when its attachment chips arrive** — `P3` — `bug` — fixed in 4470a4ba - What WEB does: reserves the row (7b25d3a95). What RN does: `src/components/email/ListAttachmentChips.tsx:89`. -- [ ] **Cold start reopens the last folder or search instead of the inbox** — `P3` — `bug` +- [x] **Cold start reopens the last folder or search instead of the inbox** — `P3` — `bug` — fixed in 9067908f - What WEB does: 64b39c504. What RN does: `src/stores/email-store.ts:2195`, `:829`. -- [ ] **List keeps its scroll position on folder, tag or search switch (#1138)** — `P3` — `bug` (from the code, not seen on a device) +- [x] **List keeps its scroll position on folder, tag or search switch (#1138)** — `P3` — `bug` (from the code, not seen on a device) — fixed in aa25d08d - What RN does: `src/screens/EmailListScreen.tsx:1590`. - [ ] **Unified inbox live updates for other logins (#1350)** — `P3` — `partial` @@ -152,7 +152,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [ ] **Hide shared mail accounts (#1228); remove from this folder (#615); folder name on all-folder results (#1172); own folders section (#1205); per-folder counter (#982); hide account dot (#912)** — `P3` — `missing` - What WEB does: `lib/shared-mail-visibility.ts:19`, `hooks/use-remove-from-folder.ts:15`, `stores/settings-store.ts:695`. What RN does: `src/components/SidebarDrawer.tsx:124-150`, `:672`; `src/screens/EmailListScreen.tsx:103`. -- [ ] **Search folder picker is flat and capped at 12** — `P3` — `partial` +- [x] **Search folder picker is flat and capped at 12** — `P3` — `partial` — fixed in e5c9433a - What WEB does: `lib/search-scope-folders.ts`. What RN does: `src/screens/EmailListScreen.tsx:1729-1740` (`slice(0, 12)`). - [ ] **Tags without a definition are grey; no account tint (#1052); tag visibility, nesting, order** — `P3` — `partial` @@ -167,7 +167,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [ ] **RTL swipe sides** — `P3` — `partial` (old 08 item; web doesn't swap either) - What RN does: `src/components/SwipeableRow.tsx:353`. -- [ ] **`/mail/folder/` deep link opens only the tab** — `P3` — `bug` +- [x] **`/mail/folder/` deep link opens only the tab** — `P3` — `bug` — fixed in ed490b71 (folder of the active account; `?account=` switches first as before) - What RN does: `src/navigation/linking.ts:134` parses it, `:285-287` drops it. ### Reading mail @@ -189,10 +189,10 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - What WEB does: `lib/verification-code.ts:275`, `components/email/email-viewer.tsx:1378`, setting at `stores/settings-store.ts:540`. - What RN does: nothing (`src/components/email/MessageHeader.tsx`, `src/screens/EmailListScreen.tsx`). -- [ ] **A mail full of blockquotes freezes the reader** — `P3` — `bug` +- [x] **A mail full of blockquotes freezes the reader** — `P3` — `bug` — fixed in 3243714d - What WEB does: single walk (`lib/quote-collapse.ts:92`). What RN does: quadratic walk (`src/lib/quote-collapse.ts:95-112`). -- [ ] **Attachment names keep direction-override characters** — `P3` — `bug` +- [x] **Attachment names keep direction-override characters** — `P3` — `bug` — fixed in a930d177 - What WEB does: `lib/bidi.ts:10`. What RN does: `src/lib/attachment-display.ts:34`, `src/lib/email-export.ts:56`. - [x] **Part names that are paths (#1371, #1373)** — `P3` — `bug` — fixed in 4d545f8f @@ -201,7 +201,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [ ] **Dark mode recolours instead of inverting** — `P3` — `missing` - What WEB does: `lib/email-dark-mode.ts:48`, `:71`. What RN does: `filter: invert(1) hue-rotate(180deg)` (`src/lib/email-html.ts:500`); mail's own dark styles follow the system scheme (`:640`). -- [ ] **"via" badge repeats the sender's address** — `P3` — `bug` +- [x] **"via" badge repeats the sender's address** — `P3` — `bug` — fixed in fac54b41 - What WEB does: `components/email/email-identity-badge.tsx:69`. What RN does: `src/components/email/MessageHeader.tsx:116-117`, `:223`. - [ ] **Fixed-width tables don't reflow (#1020)** — `P3` — `partial` @@ -210,7 +210,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [ ] **Invitation banner: View in calendar, collapse, counter-proposal review** — `P3` — `missing` (old 03 item) - What WEB does: `components/email/calendar-invitation-banner.tsx:727`, `:463`, `:245`. What RN does: `src/components/email/CalendarInvitationBanner.tsx:153`. -- [ ] **Quick reply can be turned off (#1370)** — `P3` — `missing` +- [x] **Quick reply can be turned off (#1370)** — `P3` — `missing` — fixed in 56e4a1e0 (setting `quickReplyEnabled`; the switch is the platform agent's) - What WEB does: `stores/settings-store.ts:560`. What RN does: always rendered (`src/screens/EmailThreadScreen.tsx:1233`). ### Composer and sending diff --git a/docs/parity/11-webmail-1.19.md b/docs/parity/11-webmail-1.19.md index 31968f72..eb86bff8 100644 --- a/docs/parity/11-webmail-1.19.md +++ b/docs/parity/11-webmail-1.19.md @@ -19,7 +19,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; ### Reading mail -- [ ] **A forged From keeps the real sender's favicon and contact photo** — `P2` — `bug` +- [x] **A forged From keeps the real sender's favicon and contact photo** — `P2` — `bug` — fixed in 0b0fc021 - What WEB does: when the sender check fails or is unverified, the avatar shows a warning shield instead of a photo, logo or initials, in the reader, the conversation and the list (`components/ui/avatar.tsx:323`, `hooks/use-sender-warning.ts:13`, `components/email/email-viewer.tsx:721`). The list fetch asks for `header:Authentication-Results:all` (`lib/jmap/client.ts:461`). 605a3983b (#1439). - What RN does: MessageHeader works out the verdict (`src/components/email/MessageHeader.tsx:118`) but renders SenderAvatar with the claimed address (`:175`); the same in thread cards (`src/components/email/ThreadMessageCard.tsx:50`) and list rows (`src/screens/EmailListScreen.tsx:213`). SenderAvatar picks a contact photo or the domain favicon (`src/components/SenderAvatar.tsx:62-65`). List rows have no auth data (`src/api/email.ts:23-26`). - Fix hint: pass a sender warning from getSenderVerification into SenderAvatar and draw a shield; for list rows add `header:Authentication-Results:all` to the list properties. @@ -29,17 +29,17 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; - What RN does: nothing; the only banners above the body are unsubscribe and invitation (`src/components/email/MessageContent.tsx:104`, `:112`). - Fix hint: port `lib/structured-mail/` with an RN-side HTML parser passed through `parseHtml` (`lib/structured-mail/index.ts:4-5`, `:21`) and build RN cards. The admin switch needs the org policy, so default on. -- [ ] **"Plain Text Only" says it also changes reading, but the reader ignores it** — `P2` — `bug` +- [x] **"Plain Text Only" says it also changes reading, but the reader ignores it** — `P2` — `bug` — fixed in 7dc45778 - What WEB does: reads HTML mail as plain text by default or per message (`lib/html-to-readable-text.ts:39`, `components/email/email-viewer.tsx`; 1e929b0a9). - What RN does: the switch reads "Compose and read in plain text only" (`src/components/settings/ReadingSettings.tsx:208-210`), but only the composer and the placeholder check read plainTextMode (`src/lib/compose-html.ts:175`, `src/lib/placeholder-check.ts:157`); EmailBodyView never does (`src/components/EmailBodyView.tsx:529-537`). - Fix hint: render the text part (or the HTML turned into text) when the setting is on, and add a per-message "Show as plain text". -- [ ] **Mail body ignores the app's text size and iOS Dynamic Type** — `P2` — `missing` +- [x] **Mail body ignores the app's text size and iOS Dynamic Type** — `P2` — `missing` — fixed in 7b551fc3, a3650ebf - What WEB does: bodies follow the chosen text size, with a separate mail text size and a minimum font size (`lib/mail-reading.ts:32`, `:42`; c07ba1a37). - What RN does: the body is fixed at 14 px for HTML and plain text (`src/lib/email-html.ts:695`, `:979`); the app's text size scales only the app's own UI (`src/theme/dynamic.ts:48-69`). Android's WebView may follow the system font size (no textZoom set; unverified), iOS doesn't. Pinch zoom exists (`src/components/EmailBodyView.tsx:408`). - Fix hint: scale the body's base font size by the app setting and `PixelRatio.getFontScale()`. -- [ ] **Reading aids: paused animated images, reduce motion in mail, reader view, read aloud, reading width** — `P3` — `missing` +- [ ] **Reading aids: paused animated images, reduce motion in mail, reader view, read aloud, reading width** — `P3` — `missing` — reduce motion and paused animated images fixed in 6d701c4b; not done: reader view, read aloud and reading width are left to the OS - What WEB does: animations in mail stop with reduce motion (24a13cc1f), animated images pause behind a play button (bb7869798), plus reader view, read aloud, reading mask and width (caed2d313, af3019ea2, a982c3f23, 8cc0f8f25, dcc0d600f). - What RN does: none. The app reads reduce motion (`src/theme/dynamic.ts:92`) but doesn't pass it into mail. - Fix hint: pass reduce motion into the body CSS (animation off, GIFs paused) first; read aloud and reader view are left to the OS. @@ -49,54 +49,54 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; - What RN does: contact photo, DuckDuckGo favicon, initials (`src/components/SenderAvatar.tsx:38`, `:62-65`; `src/lib/avatar-utils.ts:149`). - Fix hint: use the paired webmail's `/api/bimi` once the app keeps the webmail URL (same blocker as the org policy, file 10). -- [ ] **A DKIM pass reported only by `header.i` doesn't count (#1420)** — `P3` — `bug` +- [x] **A DKIM pass reported only by `header.i` doesn't count (#1420)** — `P3` — `bug` — fixed in 49d43729 - What WEB does: takes the signer domain from `header.i` when `header.d` is missing (`lib/email-headers.ts:282`, `:346`). f8f2e93cd. - What RN does: reads `header.d` only (`src/lib/email-headers.ts:397`); such mail shows as unverified and its invitations lose trust. - Fix hint: port identityDomain. -- [ ] **Reply and quick reply are offered on drafts and scheduled messages in a conversation** — `P3` — `bug` +- [x] **Reply and quick reply are offered on drafts and scheduled messages in a conversation** — `P3` — `bug` — fixed in 35297379 - What WEB does: hides Reply and Reply all on a conversation's drafts and scheduled messages (`components/email/email-viewer.tsx:881`). 8b29254a6 (#1404). - What RN does: every card is replyable (`src/screens/EmailThreadScreen.tsx:1212`, `src/components/email/ThreadMessageCard.tsx:68-85`); quick reply targets the last member, which can be the user's own draft (`src/screens/EmailThreadScreen.tsx:1124`). - Fix hint: `replyable={false}` for `$draft` and scheduled members; point quick reply at the newest message that is neither. -- [ ] **Bounce and receipt report parts show as list chips and go along on forward** — `P3` — `bug` — forward part fixed in 482e9a20; list chips and the global- types in isReportPart are the reader pass +- [x] **Bounce and receipt report parts show as list chips and go along on forward** — `P3` — `bug` — fixed in 7772da84 (list chips, global- types), 482e9a20 (forward, compose) - What WEB does: leaves `message/delivery-status`, `message/disposition-notification` and their `global-` forms out of list chips and forwards (`lib/attachment-visibility.ts:73`, `components/email/email-composer.tsx:985`). 8a09ba573. - What RN does: the reader hides them (`src/lib/attachment-display.ts:56`, `:130`), list chips and forwards don't (`src/lib/list-attachments.ts:21`, `src/lib/reply-compose.ts:14-19`); isReportPart misses the `global-` types. - Fix hint: filter isReportPart in both places and add the two global types. -- [ ] **Attachment icons in the reader aren't coloured by file type** — `P3` — `partial` +- [x] **Attachment icons in the reader aren't coloured by file type** — `P3` — `partial` — fixed in 4849cf23 - What WEB does: reader chips use the list's per-type icon and colour (`components/email/attachment-chips.tsx`). a78d03a18. - What RN does: list chips are typed (`src/components/email/ListAttachmentChips.tsx:96`), reader chips use a plain paperclip (`src/components/email/AttachmentChips.tsx:244`). - Fix hint: reuse attachmentKind in AttachmentChips. -- [ ] **Avatar initials are always white, even on light colours** — `P3` — `bug` +- [x] **Avatar initials are always white, even on light colours** — `P3` — `bug` — fixed in 6f66fa06 - What WEB does: picks black or white initials by contrast (`components/ui/avatar.tsx:174-182`; 2e13accad). - What RN does: always `#ffffff` (`src/components/SenderAvatar.tsx:130-133`); several palette colours give 3.1 to 3.7:1 (`src/lib/avatar-utils.ts:19-20`, `:47-48`). - Fix hint: port initialsColor. ### Mail list and search -- [ ] **Search results can't be moved into the open folder (#1452)** — `P3` — `bug` +- [x] **Search results can't be moved into the open folder (#1452)** — `P3` — `bug` — fixed in 9a4515f9 - What WEB does: a search across folders keeps the open folder selected, and hits from other folders can move into it (`lib/move-targets.ts:71`). dafdda534. - What RN does: the move sheet greys out the current folder (`src/components/MoveSheet.tsx:110-115`) and the single-row pick returns on it (`src/screens/EmailListScreen.tsx:1904-1918`), even for rows from other folders. - Fix hint: in search mode mark a folder current only when the rows are already in it. -- [ ] **No Undo for read/unread, star and tag changes** — `P3` — `missing` +- [x] **No Undo for read/unread, star and tag changes** — `P3` — `missing` — fixed in da1da0ec - What WEB does: the toast offers Undo and puts back each message's previous keywords (`lib/email-action-toast.ts:86`). ee17c0dba. - What RN does: UndoEntry covers archive, delete, move and spam only (`src/stores/email-store.ts:322`). - Fix hint: a `keywords` undo kind with the original keywords per item. -- [ ] **No Sort menu in the list header** — `P3` — `partial` +- [x] **No Sort menu in the list header** — `P3` — `partial` — fixed in 1cbe28b1, beff8744 - What WEB does: Sort menu with the order presets in the list header (`components/email/mail-list-menus.tsx`). e3f1ccce7. - What RN does: the header only flips the direction (`src/screens/EmailListScreen.tsx:1193`); presets live in Settings > Layout (`src/components/settings/LayoutSettings.tsx:88-104`). - Fix hint: a sort button that opens the presets. -- [ ] **Tag counts don't use Keyword/get where the server counts keywords (#1399)** — `P3` — `missing` +- [x] **Tag counts don't use Keyword/get where the server counts keywords (#1399)** — `P3` — `missing` — fixed in eb6bf6d3 - What WEB does: one Keyword/get when the capability has `supportsCounts`, else two Email/query per tag (`lib/jmap/client.ts:2721`, `:2737`). - What RN does: always two queries per tag (`src/api/tag-counts.ts:22-40`). - Fix hint: same check and fallback. Stalwart doesn't advertise counts, so low value. -- [ ] **The folder list doesn't say which folder is open, and the unread count reads "Show unread"** — `P3` — `bug` +- [x] **The folder list doesn't say which folder is open, and the unread count reads "Show unread"** — `P3` — `bug` — fixed in e3a34eb1 - What WEB does: says which folder is open and how many are unread (7c282f1f4, 80a2a212a). - What RN does: folder rows have no selected state (`src/components/SidebarDrawer.tsx:170-180`); the count is a button labelled "Show unread" (`:126-133`). - Fix hint: `accessibilityState.selected` and a label like "Inbox, 3 unread". @@ -113,12 +113,12 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; - What RN does: seeds the full quote into the editor (`src/screens/ComposeScreen.tsx:562-615`). - Fix hint: hide the quoted block in the editor behind a toggle, keep it in the sent body. -- [ ] **Tag picker can't make a tag; template picker can't open the template manager** — `P3` — `missing` — template half fixed in 2c1f266f; not done: the tag half lives in TagSheet and the thread tag sheet (reader files) +- [x] **Tag picker can't make a tag; template picker can't open the template manager** — `P3` — `missing` — fixed in e09cd395 (new tag), 2c1f266f (template manager, compose) - What WEB does: "New tag" in the tag picker (`components/email/tag-picker.tsx:214`), "Manage" in the template picker (`components/templates/template-picker.tsx:227`). 19676a14f. - What RN does: tag sheets list existing tags only (`src/screens/EmailThreadScreen.tsx:1489`, `src/components/TagSheet.tsx`); the template sheet shows "No templates yet" with no way on (`src/components/TemplateSheet.tsx:148-150`). - Fix hint: a "New tag" row and a "Manage templates" link. -- [ ] **No optional confirm before sending, deleting or archiving** — `P3` — `missing` — send part fixed in db8eb731 (composer and quick reply); delete and archive are the reader pass +- [x] **No optional confirm before sending, deleting or archiving** — `P3` — `missing` — fixed in db8eb731 (send, composer and quick reply), 9794dc03 (delete and archive) - What WEB does: `sendConfirmation`, `confirmDelete`, `confirmArchive` (`components/email/email-composer.tsx:501`, `lib/mail-removal-confirm.ts:24`, `lib/accessibility-settings.ts:93-94`). 4d91d9b29, 374ae9c8d. - What RN does: asks only before a permanent delete (`src/lib/delete-confirm.ts:27`). - Fix hint: three settings and an Alert in the send, delete and archive paths, swipes and quick reply included. diff --git a/locales/rn/en.json b/locales/rn/en.json index d2c7cbf8..fb90bce8 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -453,6 +453,17 @@ } }, "email_list": { + "batch_actions": { + "select_row": "Select {sender}: {subject}" + }, + "confirm_remove": { + "archive": "Archive", + "archive_message": "{count, plural, one {It moves to your archive.} other {They move to your archive.}}", + "archive_title": "{count, plural, one {Archive this email?} other {Archive # emails?}}", + "delete": "Delete", + "delete_message": "{count, plural, one {It moves to the trash.} other {They move to the trash.}}", + "delete_title": "{count, plural, one {Delete this email?} other {Delete # emails?}}" + }, "cross_account_move_offline": "Moving between accounts needs a connection", "emails_archived_count": "{count, plural, one {# email} other {# emails}} archived", "emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}", @@ -490,6 +501,17 @@ "scope_this_folder": "This folder", "search_placeholder": "Search mail...", "showing_cached": "Showing cached mail", + "sort": { + "custom": "Custom order (in Settings)", + "largest": "Largest first", + "newest": "Newest first", + "oldest": "Oldest first", + "sender": "Sender (A–Z)", + "starred": "Starred first", + "subject": "Subject (A–Z)", + "title": "Sort", + "unread": "Unread first" + }, "sort_toggle_hint": "Reverses the mail sort order", "swipe_move": "Move", "swipe_spam": "Spam", @@ -540,6 +562,10 @@ "load_failed": "Failed to load email", "next": "Next", "not_spam_short": "Not spam", + "new_tag": "New tag", + "new_tag_placeholder": "Tag name", + "pause_animation": "Pause animation", + "play_animation": "Play animation", "preview": { "archive_files": "{count, plural, one {# file} other {# files}}", "archive_first_files": "The first {count} files", @@ -570,7 +596,10 @@ "sender_actions": "Sender…", "set_color": "Set tag", "show_all_count": "Show all ({count})", + "show_as_plain_text": "Show as plain text", + "show_formatted": "Show formatted", "show_less": "Show less", + "shown_as_plain_text": "This message is shown as plain text.", "smime_unsupported": "Signed/encrypted with S/MIME - not supported on mobile", "source_too_large": "The message is too large to display in full. Share the full source instead.", "spam_short": "Spam", @@ -639,6 +668,8 @@ "tracking_number": "Tracking number", "until_time": "until {time}" }, + "tag_exists": "A tag with this name already exists", + "tag_name_invalid": "Choose a shorter name", "thread_loading": "Loading conversation...", "undo_send_failed": "Undo send failed", "unsubscribe_banner": { @@ -844,6 +875,9 @@ } }, "notifications": { + "emails_marked_read": "{count, plural, one {# email marked as read} other {# emails marked as read}}", + "emails_marked_unread": "{count, plural, one {# email marked as unread} other {# emails marked as unread}}", + "tag_removed_count": "{count, plural, one {Tag removed from # email} other {Tag removed from # emails}}", "vacation_filters_off": "Out of office settings saved, but your filters are switched off. Save again to turn them back on." }, "offline": { @@ -1609,6 +1643,9 @@ "directory_restricted": "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.", "unknown_user": "Unknown user" }, + "shell": { + "name_unread": "{name}, {count} unread" + }, "sidebar": { "account_error": "Needs attention", "remove_account_hint": "Long-press to remove this account", @@ -1620,7 +1657,8 @@ "sign_out_confirm_message": "The account is removed from this device. You can sign in again at any time.", "sign_out_confirm_title": "Sign out?", "sign_out_confirm_title_account": "Sign out of {account}?", - "tab_unavailable": "{name} (unavailable)" + "tab_unavailable": "{name} (unavailable)", + "unread_filter": "Show unread only ({count})" }, "structured_mail_calendar": { "action_failed": "Couldn't change your calendar. Try again.", diff --git a/src/api/__tests__/list-properties.test.ts b/src/api/__tests__/list-properties.test.ts new file mode 100644 index 00000000..626138e8 --- /dev/null +++ b/src/api/__tests__/list-properties.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', request: vi.fn() } })); + +import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES, THREAD_HEADER_PROPERTIES } from '../email'; + +// Sort by "Date sent" compares sentAt on the device for a list merged from +// several accounts, but list rows never had it, so the order fell back to +// the received date (webmail #1219). +describe('list properties', () => { + it('include sentAt, once', () => { + for (const list of [EMAIL_LIST_PROPERTIES, EMAIL_FULL_PROPERTIES, THREAD_HEADER_PROPERTIES]) { + expect(list.filter((p) => p === 'sentAt')).toHaveLength(1); + } + }); +}); diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts index 2c7c805d..eb2d1737 100644 --- a/src/api/__tests__/live-stalwart-mail.test.ts +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -37,7 +37,11 @@ import { searchEmails, sendEmail, MAILBOX_PROPERTIES, + queryEmailPage, } from '../email'; +import { senderWarningOf } from '../../lib/email-headers'; +import { scheduledEmailIdsInThread } from '../thread-scheduled'; +import { fetchTagCounts, KEYWORDS_CAPABILITY } from '../tag-counts'; import { uploadBytes } from '../blob'; import { getIdentities, updateIdentity } from '../identity'; import { isJMAPSetError } from '../jmap-result'; @@ -460,4 +464,75 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => { } }, 60_000); }); + + describe('mail list (reader pass)', () => { + it('lists the Authentication-Results headers so a forged From gets the warning shield (bulwarkmail/webmail#1439)', async () => { + const subject = `forged ${mailTag}`; + const raw = [ + 'Authentication-Results: mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example', + 'From: Bank ', + `To: ${USER}`, + `Subject: ${subject}`, + `Date: ${new Date().toUTCString()}`, + `Message-ID: `, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=utf-8', + '', + 'hello', + '', + ].join('\r\n'); + const { blobId } = await uploadBytes(new TextEncoder().encode(raw), 'message/rfc822'); + const id = await importEmailBlob(blobId, scratch, {}); + const page = await queryEmailPage(scratch, { limit: 10 }); + const row = page.list.find((e) => e.id === id); + expect(row).toBeDefined(); + expect(senderWarningOf(row!)?.status).toBe('failed'); + // The Date header, for sorting by date sent in merged lists (bulwarkmail/webmail#1219). + expect(typeof row!.sentAt).toBe('string'); + // The full fetch reads the same verdict from `headers`. + const [full] = await getFullEmails([id]); + expect(senderWarningOf(full)?.status).toBe('failed'); + }, 60_000); + + it("finds a conversation's message that waits for a scheduled send (bulwarkmail/webmail#1404)", async () => { + const [identity] = await getIdentities(); + const result = await sendEmail( + { from: [{ email: USER }], to: [{ email: USER }], subject: `scheduled reply ${mailTag}`, textBody: 'live' }, + identity.id, sent, 3600, { draftsMailboxId: drafts }, + ); + try { + const [filed] = await getFullEmails([result.emailId!]); + const ids = await scheduledEmailIdsInThread(filed.threadId); + expect([...ids]).toEqual([result.emailId]); + } finally { + await cancelScheduledSend(result.emailSubmissionId!).catch(() => undefined); + } + // Once cancelled it can be replied to again. + const [filed] = await getFullEmails([result.emailId!]); + expect((await scheduledEmailIdsInThread(filed.threadId)).size).toBe(0); + }, 90_000); + + it('leaves Trash out of the tag counts (bulwarkmail/webmail#1156)', async (ctx) => { + const boxes = await getMailboxes(); + const trash = boxes.find((b) => b.role === 'trash')!.id; + const tag = 'readerpass'; + const keyword = `$label:${tag}`; + const live = await importMessage(scratch, `tag live ${mailTag}`, 'a'); + const gone = await importMessage(trash, `tag deleted ${mailTag}`, 'b', { $seen: true }); + // Set afterwards: 0.16 drops the keywords Email/import is given. + await patchKeywordsForEmails([live, gone], { [keyword]: true }); + const flags = await getEmailFlags([live]); + if (!flags.list[0]?.keywords?.[keyword]) { + // Seen on the 0.16.27 test server: Email/set answers "updated" but + // keeps only system keywords for this account. Nothing to count. + ctx.skip('the server did not keep the tag keyword'); + return; + } + const all = await eventually(() => fetchTagCounts([tag]), ([c]) => c.total === 2); + expect(all).toEqual([{ id: tag, total: 2, unread: 1 }]); + expect(await fetchTagCounts([tag], [undefined], { '': [trash] })).toEqual([{ id: tag, total: 1, unread: 1 }]); + // Stalwart answers Keyword/get for no keyword counters (no capability). + expect(client.currentSession?.capabilities?.[KEYWORDS_CAPABILITY]).toBeUndefined(); + }, 60_000); + }); }); diff --git a/src/api/__tests__/tag-counts.test.ts b/src/api/__tests__/tag-counts.test.ts index ea758a48..288eb4e9 100644 --- a/src/api/__tests__/tag-counts.test.ts +++ b/src/api/__tests__/tag-counts.test.ts @@ -5,11 +5,13 @@ vi.mock('../jmap-client', () => ({ accountId: 'c', request: vi.fn(), getMaxCallsInRequest: vi.fn(() => 16), + currentSession: { capabilities: {} as Record }, + hasAccountCapability: vi.fn(() => true), }, })); import { jmapClient } from '../jmap-client'; -import { fetchTagCounts } from '../tag-counts'; +import { fetchTagCounts, KEYWORDS_CAPABILITY } from '../tag-counts'; const mockRequest = jmapClient.request as ReturnType; const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType; @@ -30,6 +32,7 @@ function answer(totals: Record, faili beforeEach(() => { vi.clearAllMocks(); mockMaxCalls.mockReturnValue(16); + jmapClient.currentSession!.capabilities = {}; }); describe('fetchTagCounts', () => { @@ -39,11 +42,11 @@ describe('fetchTagCounts', () => { expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 1 }]); const calls = mockRequest.mock.calls[0][0]; expect(calls).toEqual([ - ['Email/query', { accountId: 'c', filter: { hasKeyword: '$label:red' }, limit: 0, calculateTotal: true }, 't0:red'], + ['Email/query', { accountId: 'c', filter: { hasKeyword: '$label:red' }, limit: 1, calculateTotal: true }, 't0:red'], ['Email/query', { accountId: 'c', filter: { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }] }, - limit: 0, + limit: 1, calculateTotal: true, }, 'u0:red'], ]); @@ -77,4 +80,53 @@ describe('fetchTagCounts', () => { expect(await fetchTagCounts(['red'], [undefined, 'team'])).toEqual([{ id: 'red', total: 2, unread: 1 }]); }); + + // The tag view leaves Trash and Junk out; its badge counted them (#1156). + it("leaves each account's Trash and Junk out of the counts", async () => { + mockRequest.mockImplementation(async (calls: Array<[string, Record, string]>) => ({ + methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 1 }, callId]), + })); + + await fetchTagCounts(['red'], [undefined, 'team'], { '': ['trash', 'junk'], team: ['t-trash'] }); + + const filters = mockRequest.mock.calls[0][0].map((c: unknown[]) => (c[1] as { filter: unknown }).filter); + expect(filters).toEqual([ + { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { inMailboxOtherThan: ['trash', 'junk'] }] }, + { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['trash', 'junk'] }] }, + { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { inMailboxOtherThan: ['t-trash'] }] }, + { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['t-trash'] }] }, + ]); + }); + + // One Keyword/get where the server counts keywords (#1399), else the queries. + it('asks Keyword/get when the server counts keywords, and falls back to queries when it fails', async () => { + jmapClient.currentSession!.capabilities = { [KEYWORDS_CAPABILITY]: { supportsCounts: true } }; + mockRequest.mockImplementation(async (calls: Array<[string, Record, string]>) => { + if (calls[0][0] === 'Keyword/get') { + return { methodResponses: [['Keyword/get', { list: [{ id: '$LABEL:Red', total: 7, unread: 3 }] }, '0']] }; + } + return { methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 1 }, callId]) }; + }); + + expect(await fetchTagCounts(['red', 'blue'])).toEqual([ + { id: 'red', total: 7, unread: 3 }, + { id: 'blue', total: 0, unread: 0 }, + ]); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest.mock.calls[0][0][0]).toEqual(['Keyword/get', { accountId: 'c', ids: ['$label:red', '$label:blue'] }, '0']); + + mockRequest.mockClear(); + mockRequest.mockImplementation(async (calls: Array<[string, Record, string]>) => ( + calls[0][0] === 'Keyword/get' + ? { methodResponses: [['error', { type: 'unknownMethod' }, '0']] } + : { methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 2 }, callId]) } + )); + expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 2 }]); + + // Without supportsCounts nothing asks Keyword/get. + jmapClient.currentSession!.capabilities = { [KEYWORDS_CAPABILITY]: {} }; + mockRequest.mockClear(); + await fetchTagCounts(['red']); + expect(mockRequest.mock.calls[0][0][0][0]).toBe('Email/query'); + }); }); diff --git a/src/api/__tests__/unified-inbox-filter.test.ts b/src/api/__tests__/unified-inbox-filter.test.ts index 2ddede99..c6d9e945 100644 --- a/src/api/__tests__/unified-inbox-filter.test.ts +++ b/src/api/__tests__/unified-inbox-filter.test.ts @@ -45,6 +45,15 @@ describe('buildFilter (aggregated views)', () => { }); }); +describe("buildFilter: a server's own all mailbox (bulwarkmail/webmail#1241)", () => { + it('is left out of All mail and Unread', () => { + const withAll = [...MAILBOXES, mailbox('every', 'all')]; + expect(buildFilter(withAll, { view: 'all' })).toEqual({ + operator: 'OR', conditions: [{ inMailbox: 'in' }, { inMailbox: 'projects' }], + }); + }); +}); + describe('buildFilter: searching from All mail', () => { const all = [...MAILBOXES, mailbox('arch', 'archive'), mailbox('drafts', 'drafts'), mailbox('spam', 'junk')]; diff --git a/src/api/email.ts b/src/api/email.ts index 45ca4657..c1f975c9 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -19,19 +19,32 @@ import { hasTruncatedDisplayedBody } from '../lib/email-body'; import { withInlineBodyParts } from '../lib/email-inline-parts'; import { stalwartStateWentBack } from './stalwart-state'; import { isTransientNetworkError } from '../lib/network-error'; +import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers'; -export const EMAIL_LIST_PROPERTIES = [ +const EMAIL_SUMMARY_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', + // Sorting by "Date sent" compares it on the device for a list merged from + // several accounts; without it the order fell back to receivedAt (#1219). + 'sentAt', +]; + +export const EMAIL_LIST_PROPERTIES = [ + ...EMAIL_SUMMARY_PROPERTIES, + // The sender check, so a list row shows a warning shield instead of the + // favicon or photo of a forged From (webmail #1439). + LIST_AUTH_RESULTS_PROPERTY, ]; // The viewer derives reply threading (In-Reply-To/References), Reply-To // handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests -// from these; without `messageId`/`headers` none of that can work. +// from these; without `messageId`/`headers` none of that can work. The full +// `headers` carry Authentication-Results, so the list's header property is +// left out. export const EMAIL_FULL_PROPERTIES = [ - ...EMAIL_LIST_PROPERTIES, + ...EMAIL_SUMMARY_PROPERTIES, 'bodyStructure', 'textBody', 'htmlBody', 'bodyValues', - 'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt', + 'attachments', 'blobId', 'bcc', 'replyTo', 'messageId', 'inReplyTo', 'references', 'headers', ]; @@ -1014,7 +1027,7 @@ export async function getEmailFlags( } // What a collapsed conversation card shows; `sentAt` is the date it prints. -export const THREAD_HEADER_PROPERTIES = [...EMAIL_LIST_PROPERTIES, 'sentAt']; +export const THREAD_HEADER_PROPERTIES = EMAIL_LIST_PROPERTIES; /** A conversation's members, oldest first, without bodies. */ export interface ThreadHeaders { diff --git a/src/api/tag-counts.ts b/src/api/tag-counts.ts index 8b4aa009..65065a1e 100644 --- a/src/api/tag-counts.ts +++ b/src/api/tag-counts.ts @@ -1,5 +1,8 @@ import { jmapClient } from './jmap-client'; -import type { JMAPMethodCall } from './types'; +import { CAPABILITIES, type JMAPMethodCall } from './types'; + +/** Bulwark's keyword counters (webmail lib/jmap/client.ts KEYWORDS_CAPABILITY). */ +export const KEYWORDS_CAPABILITY = 'https://bulwarkmail.com/ns/jmap/keywords'; export interface TagCount { /** Tag id (the part after `$label:`). */ @@ -17,33 +20,61 @@ export interface TagCount { * allows. Port of the webmail's `fetchTagCounts` (stores/email-store.ts). A * call that fails, such as for an account that can't be reached, counts 0 * rather than sinking the rest. + * + * `excluded` names each account's Trash and Junk (key '' = the own account): + * the counts leave them out like the tag view does (webmail #1156). An + * account whose server counts keywords itself (Bulwark's keywords capability + * with `supportsCounts`) is asked once with Keyword/get instead (#1399); its + * counters cover the whole account, as the webmail's do. */ export async function fetchTagCounts( tagIds: string[], accountIds: Array = [undefined], + excluded: Record = {}, ): Promise { if (tagIds.length === 0) return []; const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest()); const counts = new Map(tagIds.map((id) => [id, { id, total: 0, unread: 0 }])); + const queried: Array = []; + for (const override of accountIds) { + const counted = countsKeywords(override ?? jmapClient.accountId) + ? await tagCountsFromKeywords(tagIds, override ?? jmapClient.accountId) + : null; + if (!counted) { + queried.push(override); + continue; + } + for (const c of counted) { + const entry = counts.get(c.id)!; + entry.total += c.total; + entry.unread += c.unread; + } + } + const calls: JMAPMethodCall[] = []; const targets = new Map(); - accountIds.forEach((override, index) => { + queried.forEach((override, index) => { const accountId = override ?? jmapClient.accountId; + const skip = excluded[override ?? ''] ?? []; + const exclusion = skip.length > 0 ? [{ inMailboxOtherThan: skip }] : []; for (const id of tagIds) { const keyword = `$label:${id}`; targets.set(`t${index}:${id}`, { id, kind: 'total' }); calls.push(['Email/query', { accountId, - filter: { hasKeyword: keyword }, - limit: 0, + filter: exclusion.length > 0 + ? { operator: 'AND', conditions: [{ hasKeyword: keyword }, ...exclusion] } + : { hasKeyword: keyword }, + // 1, not 0: Stalwart takes 0 as "no limit" and returns every id. + limit: 1, calculateTotal: true, }, `t${index}:${id}`]); targets.set(`u${index}:${id}`, { id, kind: 'unread' }); calls.push(['Email/query', { accountId, - filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] }, - limit: 0, + filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }, ...exclusion] }, + limit: 1, calculateTotal: true, }, `u${index}:${id}`]); } @@ -63,3 +94,30 @@ export async function fetchTagCounts( } return tagIds.map((id) => counts.get(id)!); } + +/** Whether the server reports every keyword's total and unread in Keyword/get. */ +function countsKeywords(accountId: string): boolean { + const capability = jmapClient.currentSession?.capabilities?.[KEYWORDS_CAPABILITY] as { supportsCounts?: unknown } | undefined; + return capability?.supportsCounts === true && jmapClient.hasAccountCapability(KEYWORDS_CAPABILITY, accountId); +} + +/** Counts from the server's keyword counters, or null so the caller queries instead. */ +async function tagCountsFromKeywords(tagIds: string[], accountId: string): Promise { + try { + const res = await jmapClient.request( + [['Keyword/get', { accountId, ids: tagIds.map((id) => `$label:${id}`) }, '0']], + [CAPABILITIES.CORE, KEYWORDS_CAPABILITY], + ); + const [name, body] = res.methodResponses?.[0] ?? []; + if (name !== 'Keyword/get' || !Array.isArray(body?.list)) return null; + // Keywords are case-insensitive (RFC 8621 4.1.1). + const byId = new Map((body.list as Array<{ id: string; total?: number; unread?: number }>) + .map((k) => [k.id.toLowerCase(), k])); + return tagIds.map((id) => { + const k = byId.get(`$label:${id}`.toLowerCase()); + return { id, total: k?.total ?? 0, unread: k?.unread ?? 0 }; + }); + } catch { + return null; + } +} diff --git a/src/api/thread-scheduled.ts b/src/api/thread-scheduled.ts new file mode 100644 index 00000000..5d84762b --- /dev/null +++ b/src/api/thread-scheduled.ts @@ -0,0 +1,36 @@ +import { jmapClient } from './jmap-client'; +import { SUBMISSION_ONLY_USING } from './submissions'; + +/** + * Ids of a conversation's messages that wait for a scheduled send: a pending + * EmailSubmission whose send time is still ahead. The conversation hides + * Reply on them and points the quick reply past them, as it does for drafts + * (webmail 8b29254a6, #1404). Empty when the account can't schedule or the + * server can't answer: the cards then stay as they were. + */ +export async function scheduledEmailIdsInThread(threadId: string, accountId?: string): Promise> { + const account = accountId ?? jmapClient.accountId; + if (!account || !jmapClient.hasDelayedSend(account)) return new Set(); + try { + const res = await jmapClient.request([ + ['EmailSubmission/query', { accountId: account, filter: { threadIds: [threadId], undoStatus: 'pending' } }, '0'], + ['EmailSubmission/get', { + accountId: account, + '#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' }, + properties: ['emailId', 'sendAt', 'undoStatus'], + }, '1'], + ], SUBMISSION_ONLY_USING); + const [name, body] = res.methodResponses?.[1] ?? []; + if (name !== 'EmailSubmission/get') return new Set(); + const now = Date.now(); + const ids = new Set(); + for (const s of (body.list as Array<{ emailId?: string; sendAt?: string; undoStatus?: string }>) ?? []) { + if (!s.emailId || s.undoStatus !== 'pending') continue; + const at = s.sendAt ? new Date(s.sendAt).getTime() : NaN; + if (Number.isFinite(at) && at > now) ids.add(s.emailId); + } + return ids; + } catch { + return new Set(); + } +} diff --git a/src/api/unified-inbox.ts b/src/api/unified-inbox.ts index 7bc53957..a49475d4 100644 --- a/src/api/unified-inbox.ts +++ b/src/api/unified-inbox.ts @@ -8,6 +8,7 @@ import { keywordPointer, mailboxPointer } from './patch-pointer'; import { secureFetch } from '../lib/client-cert'; import { freshStoredCredentials } from '../lib/stored-token-refresh'; import { textConditions } from '../lib/search-utils'; +import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers'; // Aggregated views across accounts ("All inboxes", "All Sent", All mail / // Unread / Starred). Because the JMAP client is a single-account singleton @@ -20,7 +21,9 @@ import { textConditions } from '../lib/search-utils'; const EMAIL_LIST_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', - 'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', + 'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', 'sentAt', + // The sender check, for the warning shield on a forged From (#1439). + LIST_AUTH_RESULTS_PROPERTY, ]; const SESSION_CACHE_TTL_MS = 10 * 60 * 1000; @@ -29,9 +32,13 @@ const MAILBOX_CACHE_TTL_MS = 60 * 1000; export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash'; export type CrossView = 'all' | 'unread' | 'starred'; -/** Roles left out of the cross-account All mail / Unread / Starred views. */ +/** + * Roles left out of the cross-account All mail / Unread / Starred views. A + * server's own "all" mailbox holds every message again, so listing it beside + * the real folders doubled them (webmail lib/jmap/types.ts, #1241). + */ export const CROSS_EXCLUDED_ROLES: ReadonlySet = new Set([ - 'junk', 'spam', 'sent', 'archive', 'trash', 'drafts', + 'junk', 'spam', 'sent', 'archive', 'trash', 'drafts', 'all', ]); export interface UnifiedEmail extends Email { diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index be2b4b8b..bf6161b4 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensions } from 'react-native'; +import { View, StyleSheet, Linking, Platform, Text, Pressable, PixelRatio, useWindowDimensions } from 'react-native'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -14,11 +14,15 @@ import { parseMailtoUrl } from '../lib/unsubscribe'; import { fetchInlineImageDataUri } from '../lib/email-export'; import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders'; import { senderVerificationOf } from '../lib/email-headers'; +import { plainTextReading } from '../lib/html-to-readable-text'; +import { buildMailTextSizeScript, mailTextFactor } from '../lib/mail-reading'; +import { buildPauseAnimatedImagesScript, withReducedMotion } from '../lib/mail-motion'; import { useHasContacts } from '../lib/capabilities'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; import { spacing, typography, type ThemePalette } from '../theme/tokens'; +import { useShouldAnimate, useTypography } from '../theme/dynamic'; import { useColors, useResolvedTheme } from '../theme/colors'; import { perfEndAfterPaint, perfLap } from '../lib/perf-marks'; import type { RootStackParamList } from '../navigation/types'; @@ -54,6 +58,13 @@ interface EmailBodyViewProps { * page below the header) rather than a guessed height. */ fill?: boolean; + /** + * Per-message "Show as plain text" from the More sheet; null or undefined + * follows the "Plain Text Only" setting. + */ + plainView?: boolean | null; + /** "Show formatted" in the plain-text note: turn the per-message view off. */ + onShowFormatted?: () => void; } // Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The @@ -521,6 +532,7 @@ const PINCH_ZOOM = ` export default function EmailBodyView({ email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill, + plainView: plainViewOverride, onShowFormatted, }: EmailBodyViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -535,6 +547,16 @@ export default function EmailBodyView({ const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode); const plainTextFont = useSettingsStore((s) => s.plainTextFont); const messageSpacing = useSettingsStore((s) => s.messageSpacing); + const plainTextMode = useSettingsStore((s) => s.plainTextMode); + // Body text follows the app's text size and the system font scale (webmail + // c07ba1a37); it was fixed at 14 px. The app's factor is read off its + // scaled body style, so it follows whatever steps the text size setting has. + const dyn = useTypography(); + const textFactor = mailTextFactor(dyn.body.fontSize / typography.body.fontSize, PixelRatio.getFontScale()); + // Reduce motion (the system setting, or the app's animations switch) holds + // inside mail too: CSS animations stop and animated images pause behind a + // Play button (webmail 24a13cc1f, bb7869798). + const reduceMotion = !useShouldAnimate(); const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails); const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded); const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook); @@ -562,8 +584,19 @@ export default function EmailBodyView({ if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null }; return pickEmailBody(email); }, [email, bodyOverride]); - const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]); - const text = picked.text; + const formattedHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]); + // HTML mail as plain text: "Plain Text Only" for every message, or the More + // sheet's per-message switch. The message's own text part when it has one, + // else a readable conversion that keeps link targets and image alt text + // (webmail 1e929b0a9). A plain-text view loads nothing remote, so the + // external-content banner goes too. + const plainView = plainViewOverride ?? plainTextMode; + const plainText = React.useMemo( + () => (plainView ? plainTextReading({ html: formattedHtml, text: picked.text }) : null), + [plainView, formattedHtml, picked.text], + ); + const rawHtml = plainText !== null ? null : formattedHtml; + const text = plainText ?? picked.text; // A From address the server's checks don't back may be forged: neither // honour an earlier "Always trust" for it nor offer one, or a forgery of a // trusted sender would load remote content (and trusting it would let the @@ -660,6 +693,13 @@ export default function EmailBodyView({ // Sanitised and wrapped once per message, body and settings, and reused // when the same body mounts again (lib/email-body-document). const emptyLabel = t('email_viewer.no_body_content', '(No body content available)'); + // A plain-text body in a dark app theme takes the theme's colours, not a + // fixed #0a0a0a. Only while the app itself is dark: a single message + // switched to dark in a light app has no dark palette to borrow. + const plainDarkColors = React.useMemo( + () => (resolvedTheme === 'dark' ? { background: c.background, text: c.text, link: c.textLink } : undefined), + [resolvedTheme, c.background, c.text, c.textLink], + ); const prepared = React.useMemo(() => bodyDocument({ key: `${jmapAccountId ?? ''}|${email.id}`, rawHtml, @@ -671,12 +711,21 @@ export default function EmailBodyView({ messageSpacing, plainTextFont, quoteLabels, + textFactor, + plainDarkColors, }), [ jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark, - messageSpacing, plainTextFont, quoteLabels, + messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors, ]); - const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]); + const source = React.useMemo( + () => ({ html: reduceMotion && prepared.isHtml ? withReducedMotion(prepared.html) : prepared.html }), + [prepared.html, prepared.isHtml, reduceMotion], + ); + const pauseLabels = React.useMemo(() => ({ + play: t('email_viewer.play_animation', 'Play animation'), + pause: t('email_viewer.pause_animation', 'Pause animation'), + }), [t]); const showBanner = shouldBlock && prepared.blockedExternal; // Still cut off after the API's larger refetch (or the refetch failed). const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email); @@ -686,11 +735,13 @@ export default function EmailBodyView({ // input the CSS was built from, so the DOM pass and the stylesheet agree. const injectedJs = React.useMemo( () => - (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '') + (prepared.isHtml ? buildMailTextSizeScript(textFactor) : '') + + (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '') + (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '') + (prepared.isHtml ? IMG_FIXUP_SCRIPT : '') + + (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '') + HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM, - [prepared.isHtml, prepared.applyInversion, quoteLabels], + [prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor, reduceMotion, pauseLabels], ); // Performance harness: how much of opening a message is the WebView. @@ -765,6 +816,18 @@ export default function EmailBodyView({ return ( + {plainText !== null && ( + + + {t('email_viewer.shown_as_plain_text', 'This message is shown as plain text.')} + + {onShowFormatted && ( + + {t('email_viewer.show_formatted', 'Show formatted')} + + )} + + )} {showBanner && ( @@ -821,6 +884,9 @@ export default function EmailBodyView({ // Native Android zoom would scale the viewport inside the fixed-size // container; pinch zoom is implemented in the page instead. setBuiltInZoomControls={false} + // The system font size is part of textFactor; Android's WebView + // would scale the text by it a second time. + textZoom={100} showsVerticalScrollIndicator={false} showsHorizontalScrollIndicator={false} setSupportMultipleWindows={false} @@ -895,6 +961,19 @@ function makeStyles(c: ThemePalette) { ...typography.caption, color: c.textSecondary, }, + plainNote: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + columnGap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + }, + plainNoteLink: { + ...typography.caption, + color: c.primary, + textDecorationLine: 'underline', + }, webContainer: { width: '100%', backgroundColor: 'transparent', diff --git a/src/components/MoveSheet.tsx b/src/components/MoveSheet.tsx index ac61934a..75feaf3c 100644 --- a/src/components/MoveSheet.tsx +++ b/src/components/MoveSheet.tsx @@ -13,19 +13,24 @@ import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailb import { useSheetDrag } from '../lib/use-sheet-drag'; import { useLocaleStore } from '../stores/locale-store'; import { localizeMailboxName } from '../lib/mailbox-label'; +import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon'; import type { Mailbox } from '../api/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { useSheetInsets } from '../lib/use-layout'; -function moveTargetIcon(role: string | null | undefined, name: string): AppIcon { - const lower = name.toLowerCase(); - if (role === 'inbox' || lower.includes('inbox')) return Inbox; - if (role === 'sent' || lower.includes('sent')) return Send; - if (role === 'drafts' || lower.includes('draft')) return FileIcon; - if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2; - if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban; - if (role === 'archive' || lower.includes('archive')) return Archive; - return Folder; +// By role only, like the sidebar (mailboxIconKind, #1235): a user folder +// named "web/sentry" is not the Sent folder. +const MOVE_TARGET_ICONS: Partial> = { + inbox: Inbox, + sent: Send, + drafts: FileIcon, + trash: Trash2, + junk: Ban, + archive: Archive, +}; + +function moveTargetIcon(role: string | null | undefined): AppIcon { + return MOVE_TARGET_ICONS[mailboxIconKind(role, false, false)] ?? Folder; } interface MoveSheetProps { @@ -106,7 +111,7 @@ export function MoveSheet({ {visibleNodes.map((node) => { - const Icon = moveTargetIcon(node.role, node.name); + const Icon = moveTargetIcon(node.role); const isCurrent = node.id === currentMailboxId; // A shared account's header is a grouping row, not a folder, and // Drafts is never a move target (the webmail excludes it too). diff --git a/src/components/NewTagRow.tsx b/src/components/NewTagRow.tsx new file mode 100644 index 00000000..6043f72f --- /dev/null +++ b/src/components/NewTagRow.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native'; +import { Plus } from './icons'; +import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; +import { useColors } from '../theme/colors'; +import { useLocaleStore } from '../stores/locale-store'; +import { keywordToken, useKeywordsStore, type KeywordDef } from '../stores/keywords-store'; +import { checkNewTag, newTagDefinition } from '../lib/new-tag'; + +/** + * "New tag" at the end of a tag picker: a name field; the tag gets the next + * colour of the palette, is saved with the other tags and goes onto the + * message(s) straight away (webmail components/email/tag-picker.tsx). + */ +export function NewTagRow({ keywords, onCreated }: { + /** The tags the picker lists: a new id must not repeat one of them. */ + keywords: KeywordDef[]; + /** Apply the new tag's keyword token. */ + onCreated: (token: string) => void; +}) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const addKeyword = useKeywordsStore((s) => s.add); + const [creating, setCreating] = React.useState(false); + const [name, setName] = React.useState(''); + const { error } = checkNewTag(name, keywords); + const errorText = error === 'tag_exists' + ? t('email_viewer.tag_exists', 'A tag with this name already exists') + : error === 'tag_name_invalid' ? t('email_viewer.tag_name_invalid', 'Choose a shorter name') : null; + + const create = () => { + const def = newTagDefinition(name, keywords); + if (!def) return; + addKeyword(def); + setCreating(false); + setName(''); + onCreated(keywordToken(def.id)); + }; + + if (!creating) { + return ( + setCreating(true)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + testID="tag-picker-new" + > + + {t('email_viewer.new_tag', 'New tag')} + + ); + } + return ( + + + {errorText && {errorText}} + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2, + minHeight: 44, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: c.border, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + label: { ...typography.body, color: c.textSecondary, flex: 1 }, + form: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.xs }, + input: { + ...typography.body, + color: c.text, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + minHeight: 40, + }, + inputError: { borderColor: c.error }, + error: { ...typography.caption, color: c.error }, + }); +} diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 321915c1..22396e38 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; import { useShallow } from 'zustand/react/shallow'; import { + avatarContent, + initialsColor, generateEmailAvatarColor, getContactPhotoForEmail, getEmailInitials, @@ -14,6 +16,8 @@ import type { ContactPhotoRef } from '../lib/contact-utils'; import { useContactPhotoSource } from '../lib/use-contact-photo'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; +import { useColors } from '../theme/colors'; +import { ShieldAlert } from './icons'; interface SenderAvatarProps { name?: string | null; @@ -32,23 +36,32 @@ interface SenderAvatarProps { * the contact has no photo, so no other card's photo is borrowed. */ photo?: ContactPhotoRef | null; + /** + * The message's sender check failed or is unverified (see + * useSenderWarning): a warning shield with this label instead of any photo, + * favicon or initials, so a forged From doesn't get the real sender's logo + * or photo (webmail #1439). + */ + senderWarning?: string | null; } // Mirrors the webmail `Avatar` priority for the subset available on native: -// address-book photo → company favicon (if sender domain is non-personal and -// `senderFavicons` is on) → initials over HSL-hashed color. -export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo }: SenderAvatarProps) { - const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages; +// sender warning → address-book photo → company favicon (if sender domain is +// non-personal and `senderFavicons` is on) → initials over HSL-hashed color. +export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) { + const c = useColors(); + const warned = !!senderWarning; + const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned; // Compared shallowly, so only avatars whose photo changed re-render when the // contacts change; the index behind it is built once per contacts array. const indexedPhoto = useContactsStore( - useShallow((s) => (photo === undefined ? getContactPhotoForEmail(s.contacts, email) : undefined)), + useShallow((s) => (photo === undefined && !warned ? getContactPhotoForEmail(s.contacts, email) : undefined)), ); const contactPhoto = photo === undefined ? indexedPhoto : photo; // A blob photo (Stalwart 1.0) has no source while it waits for its // download or after it failed; the favicon or initials show meanwhile. const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } = - useContactPhotoSource(disableImages ? null : contactPhoto); + useContactPhotoSource(disableImages || warned ? null : contactPhoto); const [imgError, setImgError] = React.useState(false); React.useEffect(() => { @@ -61,8 +74,11 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma const faviconDomain = getFaviconDomain(email ?? undefined); const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false; - const showPhoto = !disableImages && !!photoSource; - const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed; + const content = avatarContent({ + senderWarning, + hasPhoto: !disableImages && !!photoSource, + showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed, + }); const handleError = React.useCallback(() => { setImgError(true); @@ -75,14 +91,29 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma width: size, height: size, borderRadius: size / 2, - backgroundColor: showPhoto ? 'transparent' : showFavicon ? '#ffffff' : bgColor, + backgroundColor: content === 'warning' + ? c.errorBg + : content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor, }, style, ]; const fontSize = Math.round(size * 0.4); - if (showPhoto && photoSource) { + if (content === 'warning') { + return ( + + + + ); + } + + if (content === 'photo' && photoSource) { return ( - {initials} + {initials} ); } diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index f36a270b..82326d9c 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -15,7 +15,7 @@ import { import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useAnimDuration } from '../theme/dynamic'; -import { useEmailStore, spannedAccounts } from '../stores/email-store'; +import { useEmailStore, spannedAccounts, trashAndJunkIds } from '../stores/email-store'; import { useAuthStore } from '../stores/auth-store'; import { useAccountStore } from '../stores/account-store'; import { useSettingsStore } from '../stores/settings-store'; @@ -128,7 +128,7 @@ function RowCounts({ unread, total, showTotal, onPressUnread }: { disabled={!onPressUnread} hitSlop={8} accessibilityRole={onPressUnread ? 'button' : undefined} - accessibilityLabel={onPressUnread ? t('sidebar.show_unread', 'Show unread') : undefined} + accessibilityLabel={onPressUnread ? t('sidebar.unread_filter', 'Show unread only ({count})', { count: unread }) : undefined} > {unread} @@ -167,11 +167,19 @@ function SidebarRow({ const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const leftPad = ROW_PX_BASE + depth * INDENT_STEP; + // Which folder is open, and the unread count with the folder's name + // ("Inbox, 3 unread"), for screen readers (webmail 7c282f1f4). + const accessibleName = unread > 0 && !isAccountHeader + ? t('shell.name_unread', '{name}, {count} unread', { name: label, count: unread }) + : label; return ( [ styles.row, isSelected && styles.rowSelected, @@ -454,8 +462,10 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side React.useEffect(() => { if (!visible || !tagsExpanded || keywordDefs.length === 0 || !activeAccountId || !jmapClient.isConnected) return; const accounts = tagAccountsKey.split('|').map((id) => id || undefined); - void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts); - }, [visible, tagsExpanded, keywordDefs, tagAccountsKey, activeAccountId, tagCountsGeneration, ensureTagCounts]); + // Trash and Junk stay out of the counts as they do of the tag view (#1156). + const excluded = Object.fromEntries(accounts.map((id) => [id ?? '', trashAndJunkIds(mailboxes, id)])); + void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts, excluded); + }, [visible, tagsExpanded, keywordDefs, tagAccountsKey, mailboxes, activeAccountId, tagCountsGeneration, ensureTagCounts]); // ── Folder actions (long-press) ─────────────────────────────────────── const refFor = (mb: Mailbox) => ({ diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index 1cd7f105..7c08467f 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -13,6 +13,7 @@ import { getEmailTagIds } from '../lib/thread-utils'; import type { Email } from '../api/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { useSheetInsets } from '../lib/use-layout'; +import { NewTagRow } from './NewTagRow'; interface TagSheetProps { visible: boolean; @@ -132,6 +133,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle ); })} + onToggle(token, true)} /> diff --git a/src/components/__tests__/list-avatar-select.test.ts b/src/components/__tests__/list-avatar-select.test.ts new file mode 100644 index 00000000..49decb60 --- /dev/null +++ b/src/components/__tests__/list-avatar-select.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Selecting mail needed a long press; native #91 asks for a tap on the +// left side, as the webmail's avatar does. + +describe('list row avatar (#91)', () => { + it('toggles the row into the selection on a tap', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8'); + const avatar = src.slice(src.indexOf('{density.showAvatar && ('), src.indexOf('{/* Content */}')); + expect(avatar).toMatch(/ fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8'); + +describe('plain-text reading', () => { + it('renders the plain-text reading when the setting or the per-message switch is on', () => { + const body = src('components/EmailBodyView.tsx'); + expect(body).toMatch(/useSettingsStore\(\(s\) => s\.plainTextMode\)/); + expect(body).toMatch(/const plainView = plainViewOverride \?\? plainTextMode;/); + expect(body).toMatch(/plainTextReading\(\{ html: formattedHtml, text: picked\.text \}\)/); + // The document is built from the text, not the HTML, while it is on. + expect(body).toMatch(/const rawHtml = plainText !== null \? null : formattedHtml;/); + expect(body).toMatch(/email_viewer\.shown_as_plain_text/); + }); + + it('offers "Show as plain text" per message in the More sheet and passes it to every card', () => { + const screen = src('screens/EmailThreadScreen.tsx'); + expect(screen).toMatch(/email_viewer\.show_as_plain_text/); + expect(screen).toMatch(/plainView=\{plainOverrides\[m\.id\] \?\? null\}/); + expect(src('components/email/MessageContent.tsx')).toMatch(/plainView=\{plainView\}/); + }); +}); diff --git a/src/components/__tests__/reader-attachment-icons.test.ts b/src/components/__tests__/reader-attachment-icons.test.ts new file mode 100644 index 00000000..c4dacbc9 --- /dev/null +++ b/src/components/__tests__/reader-attachment-icons.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// List chips showed a typed, coloured icon per file; the reader's chips a +// grey paperclip for everything (webmail a78d03a18 uses the list's icons in +// the reader too). + +const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', p), 'utf8'); + +describe('reader attachment chip icons', () => { + it('draws the same typed icon and colour as the list chips', () => { + const reader = src('email/AttachmentChips.tsx'); + expect(reader).not.toMatch(//); + const list = src('email/ListAttachmentChips.tsx'); + expect(list).toMatch(/ATTACHMENT_ICONS\[kind\]/); + expect(list).toMatch(/attachmentIconColor\(kind, c\)/); + }); +}); diff --git a/src/components/__tests__/sender-warning-avatar.test.ts b/src/components/__tests__/sender-warning-avatar.test.ts new file mode 100644 index 00000000..238a9f91 --- /dev/null +++ b/src/components/__tests__/sender-warning-avatar.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { accountId: 'acc-1', request: vi.fn(), getMaxObjectsInGet: vi.fn(() => 50) }, +})); + +import { avatarContent } from '../../lib/avatar-utils'; +import { LIST_AUTH_RESULTS_PROPERTY, senderWarningOf } from '../../lib/email-headers'; +import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES } from '../../api/email'; +import type { Email } from '../../api/types'; + +// A forged From kept the real sender's favicon or contact photo: the reader +// worked out that the sender check failed, but the avatar beside it still +// showed the claimed sender's logo, and list rows had no auth data at all +// (webmail 605a3983b, #1439). Now the avatar shows a warning shield. + +const FORGED = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example'; +const GOOD = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example'; + +function row(auth: string[] | undefined): Email { + return { + id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z', + hasAttachment: false, from: [{ name: 'Bank', email: 'ceo@bank.example' }], + ...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}), + } as Email; +} + +describe('sender warning in avatars (bulwarkmail/webmail#1439)', () => { + it('asks for the Authentication-Results headers in the list fetch, not in the full one', () => { + expect(EMAIL_LIST_PROPERTIES).toContain('header:Authentication-Results:all'); + // The full fetch has `headers`, which carry the same header. + expect(EMAIL_FULL_PROPERTIES).not.toContain('header:Authentication-Results:all'); + expect(EMAIL_FULL_PROPERTIES).toContain('headers'); + }); + + it('judges a list row from the listed header and a full message from its headers', () => { + expect(senderWarningOf(row([FORGED]))?.status).toBe('failed'); + expect(senderWarningOf(row([GOOD]))).toBeNull(); + expect(senderWarningOf(row(undefined))).toBeNull(); + expect(senderWarningOf(row([]))).toBeNull(); + const full = { ...row(undefined), headers: [{ name: 'Authentication-Results', value: ` ${FORGED}` }] }; + expect(senderWarningOf(full)?.status).toBe('failed'); + }); + + it('shows the shield before a contact photo or favicon', () => { + expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showFavicon: true })).toBe('warning'); + expect(avatarContent({ senderWarning: undefined, hasPhoto: true, showFavicon: true })).toBe('photo'); + expect(avatarContent({ senderWarning: null, hasPhoto: false, showFavicon: true })).toBe('favicon'); + expect(avatarContent({ hasPhoto: false, showFavicon: false })).toBe('initials'); + }); + + it('passes the warning to the avatar in the reader, collapsed conversation cards and list rows', () => { + const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8'); + expect(src('components/email/MessageHeader.tsx')).toMatch(/senderWarning=\{verificationText\?\.label\}/); + expect(src('components/email/ThreadMessageCard.tsx')).toMatch(/senderWarning=\{senderWarning\}/); + expect(src('screens/EmailListScreen.tsx')).toMatch(/senderWarning=\{senderWarning\}/); + expect(src('screens/UnifiedInboxScreen.tsx')).toMatch(/senderWarning=\{verification \?/); + }); +}); diff --git a/src/components/__tests__/sidebar-folder-names.test.ts b/src/components/__tests__/sidebar-folder-names.test.ts new file mode 100644 index 00000000..0a0f13bf --- /dev/null +++ b/src/components/__tests__/sidebar-folder-names.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Screen readers couldn't tell which folder was open, and the unread count +// was a button read as "Show unread" with no number (webmail 7c282f1f4). + +describe('folder list names', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../SidebarDrawer.tsx'), 'utf8'); + + it('marks the open folder as selected and says its unread count with its name', () => { + expect(src).toMatch(/accessibilityState=\{isAccountHeader \? undefined : \{ selected: isSelected \}\}/); + expect(src).toMatch(/t\('shell\.name_unread', '\{name\}, \{count\} unread', \{ name: label, count: unread \}\)/); + expect(src).toMatch(/accessibilityLabel=\{accessibleName\}/); + }); + + it('names the unread filter button with its count', () => { + expect(src).not.toMatch(/sidebar\.show_unread/); + expect(src).toMatch(/t\('sidebar\.unread_filter', 'Show unread only \(\{count\}\)', \{ count: unread \}\)/); + }); +}); diff --git a/src/components/email/AttachmentChips.tsx b/src/components/email/AttachmentChips.tsx index b194485f..88599955 100644 --- a/src/components/email/AttachmentChips.tsx +++ b/src/components/email/AttachmentChips.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native'; -import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons'; +import { Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons'; import type { Attachment, Email } from '../../api/types'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -13,6 +13,8 @@ import { getAttachmentDisplayName, visibleAttachments, formatSize, } from '../../lib/attachment-display'; import { emailExportFilename } from '../../lib/download-filename'; +import { attachmentKind } from '../../lib/list-attachments'; +import { ATTACHMENT_ICONS, attachmentIconColor } from './attachment-icons'; import { ActionSheet, type ActionSheetItem } from './ActionSheet'; import { AttachmentPreviewModal } from './AttachmentPreviewModal'; import type { ExtractedAttachment } from './use-body-override'; @@ -232,6 +234,9 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne {visible.map((item) => { const busy = busyKey === item.key; + // Typed and coloured like the list's chips (webmail a78d03a18). + const kind = attachmentKind(item.type, item.name); + const Icon = ATTACHMENT_ICONS[kind]; return ( - {busy ? : } + {busy ? : } {item.name} {formatSize(item.size)} diff --git a/src/components/email/ListAttachmentChips.tsx b/src/components/email/ListAttachmentChips.tsx index a457666f..493a5940 100644 --- a/src/components/email/ListAttachmentChips.tsx +++ b/src/components/email/ListAttachmentChips.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; -import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons'; import type { Attachment, Email, Mailbox } from '../../api/types'; import { getEmailAttachments } from '../../api/email'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; @@ -13,8 +12,9 @@ import { accountIdOfRow } from '../../stores/email-store'; import { accountScopedId } from '../../lib/thread-utils'; import { attachmentKind, realAttachments, requestListAttachments, shortAttachmentName, - type AttachmentKind, type LoadListAttachments, + type LoadListAttachments, } from '../../lib/list-attachments'; +import { ATTACHMENT_ICONS, attachmentIconColor } from './attachment-icons'; import { getAttachmentDisplayName } from '../../lib/attachment-display'; import { attachmentSaveName, downloadAttachment } from '../../lib/email-export'; import { AttachmentPreviewModal } from './AttachmentPreviewModal'; @@ -23,31 +23,11 @@ import { useFilePreview } from './use-file-preview'; /** How many chips a row shows before collapsing the rest into a count. */ const MAX_CHIPS = 2; -const ICONS: Record = { - image: FileImage, - pdf: FileText, - sheet: FileSpreadsheet, - archive: FileArchive, - document: FileText, - other: FileIcon, -}; - -function iconColor(kind: AttachmentKind, c: ThemePalette): string { - switch (kind) { - case 'image': return c.tags.purple.dot; - case 'pdf': return c.tags.red.dot; - case 'sheet': return c.tags.green.dot; - case 'archive': return c.tags.amber.dot; - case 'document': return c.tags.blue.dot; - default: return c.textMuted; - } -} - /** * The row's attachment parts: the ones the email already carries (a message * opened before), else loaded lazily when it has a paperclip. */ -function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined { +export function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined { const [loaded, setLoaded] = React.useState<{ key: string; attachments: Attachment[] } | null>(null); const needsLoad = !!load && !!email.hasAttachment && !email.attachments; // Rows of a list spanning accounts can share an id (#1082). @@ -67,7 +47,10 @@ function useListAttachments(email: Email, load?: LoadListAttachments): Attachmen interface ChipsProps { email: Email; - load?: LoadListAttachments; + /** The row's parts (useListAttachments); undefined while they load. */ + attachments: Attachment[] | undefined; + /** The parts are being loaded: hold the chips' line so the row doesn't grow. */ + pending?: boolean; onOpen: (email: Email, attachment: Attachment) => void; /** Selection mode: taps belong to the row. */ disabled?: boolean; @@ -79,13 +62,23 @@ interface ChipsProps { * that file directly. */ export const ListAttachmentChips = React.memo(function ListAttachmentChips({ - email, load, onOpen, disabled, + email, attachments, pending, onOpen, disabled, }: ChipsProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const dyn = useTypography(); - const attachments = useListAttachments(email, load); const real = React.useMemo(() => realAttachments(attachments), [attachments]); + // The line the chips will take, held while they load (webmail 7b25d3a95): + // the row grew under the finger when they arrived. + if (pending && attachments === undefined) { + return ( + + + + + + ); + } if (real.length === 0) return null; const shown = real.slice(0, MAX_CHIPS); @@ -94,7 +87,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({ {shown.map((a, i) => { const kind = attachmentKind(a.type, a.name); - const Icon = ICONS[kind]; + const Icon = ATTACHMENT_ICONS[kind]; return ( - + {shortAttachmentName(a.name ?? '')} ); @@ -234,6 +227,8 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.surface, }, chipPressed: { backgroundColor: c.surfaceHover }, + // Empty space of a chip's height; a failed load leaves no grey stub behind. + placeholderChip: { width: 96, opacity: 0 }, name: { ...typography.caption, color: c.textSecondary, flexShrink: 1 }, more: { paddingHorizontal: 6, diff --git a/src/components/email/MessageContent.tsx b/src/components/email/MessageContent.tsx index 4febfb19..e974306b 100644 --- a/src/components/email/MessageContent.tsx +++ b/src/components/email/MessageContent.tsx @@ -22,6 +22,10 @@ export interface MessageContentProps { /** On screen rather than pre-rendered by the pager (gates auto-sent read receipts). */ active: boolean; themeOverride?: 'light' | 'dark' | null; + /** Per-message "Show as plain text"; null follows the "Plain Text Only" setting. */ + plainView?: boolean | null; + /** Turn the per-message plain-text view off ("Show formatted"). */ + onShowFormatted?: (email: Email) => void; onSwipe?: (direction: 'prev' | 'next') => void; onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void; onToggleStar?: (email: Email) => void; @@ -52,8 +56,8 @@ export interface MessageContentProps { * cards. */ export function MessageContent({ - email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, onSwipe, onZoomChange, - onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill, + email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, plainView, onShowFormatted, + onSwipe, onZoomChange, onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill, }: MessageContentProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -124,6 +128,8 @@ export function MessageContent({ onSwipe={onSwipe} onZoomChange={onZoomChange} themeOverride={themeOverride} + plainView={plainView} + onShowFormatted={onShowFormatted ? () => onShowFormatted(email) : undefined} bodyOverride={unwrap.override} onSettled={onBodySettled} fill={fill} diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index 4fdcbcca..123e0e67 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -12,7 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import { useAccountKeywordDefs } from '../../stores/keywords-store'; import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date'; import { - deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, + deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, viaIdentityNameShown, getSenderVerification, describeSenderVerification, type AuthenticationResults, type EmailHeaderInfo, } from '../../lib/email-headers'; @@ -138,7 +138,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA if (spoofed || identities.length === 0) return null; const fromEmail = from?.email?.toLowerCase(); const sentAs = fromEmail ? identities.find((i) => i.email?.toLowerCase() === fromEmail) : undefined; - if (sentAs) return { identity: sentAs, direction: 'from' as const }; + if (sentAs) return viaIdentityNameShown(sentAs, from) ? { identity: sentAs, direction: 'from' as const } : null; const received = findReceivingIdentity(identities, email); if (received && identities.length > 1 && received.id !== identities[0].id) { return { identity: received, direction: 'to' as const }; @@ -172,7 +172,12 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA accessibilityRole="button" accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} > - + onAddressPress(from) : undefined}> @@ -253,7 +258,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA {viaIdentity.direction === 'from' - ? t('identities.badge.identity_short', 'via {name}', { name: viaIdentity.identity.email }) + ? t('identities.badge.identity_short', 'via {name}', { name: viaIdentity.identity.name }) : t('email_viewer.received_via', 'to {name}', { name: viaIdentity.identity.email })} diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index a70b0e28..51c822ac 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -10,6 +10,8 @@ import SenderAvatar from '../SenderAvatar'; import { MessageContent, type MessageContentProps } from './MessageContent'; import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date'; import { singleLine } from '../../lib/single-line'; +import { cleanPreview } from '../../lib/preview-text'; +import { useSenderWarning } from './use-sender-warning'; interface Props extends MessageContentProps { expanded: boolean; @@ -43,11 +45,12 @@ export function ThreadMessageCard({ const unread = !email.keywords?.$seen; const starred = !!email.keywords?.$flagged; const date = emailDisplayDate(email); + const senderWarning = useSenderWarning(expanded ? undefined : email); if (!expanded) { return ( [styles.collapsed, pressed && styles.pressed]}> - + @@ -56,7 +59,7 @@ export function ThreadMessageCard({ {email.hasAttachment && } {formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)} - {singleLine(email.preview)} + {singleLine(cleanPreview(email.preview))} {starred && } diff --git a/src/components/email/attachment-icons.ts b/src/components/email/attachment-icons.ts new file mode 100644 index 00000000..88920a0b --- /dev/null +++ b/src/components/email/attachment-icons.ts @@ -0,0 +1,28 @@ +import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons'; +import type { ThemePalette } from '../../theme/tokens'; +import type { AttachmentKind } from '../../lib/list-attachments'; + +/** + * The icon of an attachment chip by file type, the same in the list and the + * reader (webmail components/email/attachment-chips.tsx, a78d03a18). + */ +export const ATTACHMENT_ICONS: Record = { + image: FileImage, + pdf: FileText, + sheet: FileSpreadsheet, + archive: FileArchive, + document: FileText, + other: FileIcon, +}; + +/** The icon's colour by file type. */ +export function attachmentIconColor(kind: AttachmentKind, c: ThemePalette): string { + switch (kind) { + case 'image': return c.tags.purple.dot; + case 'pdf': return c.tags.red.dot; + case 'sheet': return c.tags.green.dot; + case 'archive': return c.tags.amber.dot; + case 'document': return c.tags.blue.dot; + default: return c.textMuted; + } +} diff --git a/src/components/email/use-sender-warning.ts b/src/components/email/use-sender-warning.ts new file mode 100644 index 00000000..7ef27120 --- /dev/null +++ b/src/components/email/use-sender-warning.ts @@ -0,0 +1,18 @@ +import React from 'react'; +import type { Email } from '../../api/types'; +import { describeSenderVerification, senderWarningOf } from '../../lib/email-headers'; +import { useLocaleStore } from '../../stores/locale-store'; + +/** + * The sender-check label of a message whose From the receiving server's + * checks don't back, the same verdict as the notice in the reader. Avatars + * show it instead of the claimed sender's favicon or photo (webmail + * use-sender-warning.ts, #1439). Undefined when the check passed, there are + * no results to judge by, or no message is given (Sent and Drafts rows show + * the recipient, whose avatar the check says nothing about). + */ +export function useSenderWarning(email: Pick | undefined): string | undefined { + const t = useLocaleStore((s) => s.t); + const verification = React.useMemo(() => (email ? senderWarningOf(email) : null), [email]); + return verification ? describeSenderVerification(verification, t).label : undefined; +} diff --git a/src/lib/__tests__/avatar-initials-color.test.ts b/src/lib/__tests__/avatar-initials-color.test.ts new file mode 100644 index 00000000..02891d99 --- /dev/null +++ b/src/lib/__tests__/avatar-initials-color.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { generateEmailAvatarColor, initialsColor } from '../avatar-utils'; +import { parseColor, relativeLuminance } from '../event-colors'; + +// Initials were always white, which on the yellow, green and cyan hues of +// `hsl(h, 70%, 50%)` came out near 1.5:1 (webmail 2e13accad). + +function contrast(a: { r: number; g: number; b: number; a: number }, b: { r: number; g: number; b: number; a: number }) { + const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +function hsl(h: number): { r: number; g: number; b: number; a: number } { + // Same formula as the avatar's: 70 % saturation, 50 % lightness. + const s = 0.7; const l = 0.5; + const k = (n: number) => (n + h / 30) % 12; + const f = (n: number) => l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); + return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), a: 1 }; +} + +describe('avatar initials colour', () => { + it('picks black on light hues and white on dark ones', () => { + expect(initialsColor('hsl(60, 70%, 50%)')).toBe('#000000'); + expect(initialsColor('hsl(180, 70%, 50%)')).toBe('#000000'); + expect(initialsColor('hsl(240, 70%, 50%)')).toBe('#ffffff'); + expect(initialsColor('#1d4ed8')).toBe('#ffffff'); + expect(initialsColor('not a colour')).toBe('#ffffff'); + }); + + it('reaches 4.5:1 on every generated hue', () => { + for (let h = 0; h < 360; h++) { + const ink = parseColor(initialsColor(`hsl(${h}, 70%, 50%)`))!; + expect(contrast(hsl(h), ink), `hue ${h}`).toBeGreaterThanOrEqual(4.5); + } + expect(generateEmailAvatarColor('Ann')).toMatch(/^hsl\(\d+, 70%, 50%\)$/); + }); + + it('is what the sender avatar draws its initials in', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../../components/SenderAvatar.tsx'), 'utf8'); + expect(src).toMatch(/color: initialsColor\(bgColor\)/); + }); +}); diff --git a/src/lib/__tests__/bidi.test.ts b/src/lib/__tests__/bidi.test.ts new file mode 100644 index 00000000..2e8bc9f9 --- /dev/null +++ b/src/lib/__tests__/bidi.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { stripBidiControls } from '../bidi'; +import { getAttachmentDisplayName } from '../attachment-display'; + +// A sender-chosen attachment name kept its direction-override characters, +// so `reportfdp.exe` showed as `reportexe.pdf`: an executable +// dressed up as a PDF (webmail lib/bidi.ts). + +const ch = (code: number) => String.fromCharCode(code); +const RLO = ch(0x202e); + +describe('direction-override characters in attachment names', () => { + it('strips the embeddings, overrides, isolates and LRM/RLM marks', () => { + const controls = [0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069].map(ch).join(''); + expect(stripBidiControls(`a${controls}b`)).toBe('ab'); + // Hebrew and Arabic letters stay. + expect(stripBidiControls('שלום.pdf')).toBe('שלום.pdf'); + }); + + it('shows the real extension in the chip name', () => { + expect(getAttachmentDisplayName(`report${RLO}fdp.exe`, 'application/octet-stream')).toBe('reportfdp.exe'); + // A name of nothing but controls falls back to the type label. + expect(getAttachmentDisplayName(RLO, 'application/pdf')).toBe('Document.pdf'); + }); +}); diff --git a/src/lib/__tests__/email-export.test.ts b/src/lib/__tests__/email-export.test.ts index fd720a24..68b76c1b 100644 --- a/src/lib/__tests__/email-export.test.ts +++ b/src/lib/__tests__/email-export.test.ts @@ -246,6 +246,12 @@ describe('cachePreviewFile (in-app preview)', () => { expect(file.name).toBe('a_b_.txt'); }); + it('drops direction-override characters, so the real extension is the one the file gets shown with', async () => { + const rlo = String.fromCharCode(0x202e); + const file = await cachePreviewFile('blob-b', `report${rlo}fdp.exe`, 'application/octet-stream'); + expect(file.name).toBe('reportfdp.exe'); + }); + it('removes its folder and rethrows when the download fails', async () => { vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404')); diff --git a/src/lib/__tests__/email-headers.test.ts b/src/lib/__tests__/email-headers.test.ts index 6ff67182..286769e6 100644 --- a/src/lib/__tests__/email-headers.test.ts +++ b/src/lib/__tests__/email-headers.test.ts @@ -85,6 +85,16 @@ describe('getSenderVerification', () => { expect(getSenderVerification(undefined, 'a@bank.example')).toBeNull(); }); + it('takes the signing domain from header.i when the receiver writes no header.d (bulwarkmail/webmail#1420)', () => { + const from = 'noreply@example.org'; + expect(getSenderVerification(parseAuthenticationResults('mx.example.net; dkim=pass header.i=@example.org'), from)).toBeNull(); + expect(getSenderVerification(parseAuthenticationResults('mx; dkim=pass header.i=bounce@mail.example.org'), from)).toBeNull(); + expect(getSenderVerification(parseAuthenticationResults('mx; dkim=pass header.i=@other.example'), from)) + .toEqual({ status: 'unverified', domain: 'example.org' }); + // header.d still wins when both are there. + expect(parseAuthenticationResults('mx; dkim=pass header.d=a.example header.i=@b.example').dkim?.domain).toBe('a.example'); + }); + it('does not count a HELO pass', () => { const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; spf=none smtp.mailfrom=bank.example'); expect(getSenderVerification(auth, 'a@bank.example')?.status).toBe('unverified'); diff --git a/src/lib/__tests__/html-to-readable-text.test.ts b/src/lib/__tests__/html-to-readable-text.test.ts new file mode 100644 index 00000000..e23183ee --- /dev/null +++ b/src/lib/__tests__/html-to-readable-text.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { htmlToReadableText, plainTextReading } from '../html-to-readable-text'; + +// "Plain Text Only" promised plain-text reading, but the reader showed the +// HTML anyway. These are the webmail's cases for its converter +// (lib/__tests__/html-to-readable-text.test.ts) plus the HTML a DOM parser +// fixes up on its own. + +describe('htmlToReadableText', () => { + it('keeps paragraphs and line breaks', () => { + expect(htmlToReadableText('

Hello Ann,

first line
second line

')) + .toBe('Hello Ann,\n\nfirst line\nsecond line'); + }); + + it('keeps link targets and image alt text', () => { + expect(htmlToReadableText('

Read the report Logo

')) + .toBe('Read the report (https://ex.com/a) [Logo]'); + }); + + it('does not repeat a target that is the link text, and drops script and style', () => { + expect(htmlToReadableText('ex.com')) + .toBe('ex.com'); + expect(htmlToReadableText('Write to Ann')) + .toBe('Write to Ann (ann@ex.com)'); + }); + + it('turns layout tables into lines and lists into bullets', () => { + const html = '
NameAnn
CityBerlin
  • one
  • two
'; + expect(htmlToReadableText(html)).toBe('Name Ann\nCity Berlin\n\n• one\n• two'); + }); + + it('skips hidden preheaders and images without alt text', () => { + expect(htmlToReadableText('
preheader

Body

')).toBe('Body'); + }); + + it('reads a whole document: head, entities, comments and unclosed tags', () => { + const html = 'T' + + '

Fish & chips €5

Next deal' + + '

  • one
  • two
'; + expect(htmlToReadableText(html)).toBe('Fish & chips €5\n\nNext deal (https://ex.com/?a=1&b=2)\n\n• one\n• two'); + }); + + it('keeps preformatted text and marks quotes', () => { + expect(htmlToReadableText('
a  b\n  c
')).toBe('a b\n c'); + expect(htmlToReadableText('

On Monday Bob wrote:

hi
')).toBe('On Monday Bob wrote:\n\n> hi'); + }); +}); + +describe('plainTextReading', () => { + it("prefers the message's own text part, else converts the HTML, and leaves plain-text mail alone", () => { + expect(plainTextReading({ html: '

Hi there

', text: 'Hi there (text part)' })).toBe('Hi there (text part)'); + expect(plainTextReading({ html: '

Hi there

', text: null })).toBe('Hi there'); + expect(plainTextReading({ html: '

Hi

', text: ' ' })).toBe('Hi'); + expect(plainTextReading({ html: null, text: 'just text' })).toBeNull(); + }); +}); diff --git a/src/lib/__tests__/list-paperclip.test.ts b/src/lib/__tests__/list-paperclip.test.ts new file mode 100644 index 00000000..59324825 --- /dev/null +++ b/src/lib/__tests__/list-paperclip.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { showsPaperclip } from '../list-attachments'; +import type { Attachment } from '../../api/types'; + +// A message with nothing but inline images (a signature logo) kept its +// paperclip in the list, and the row grew when its chips arrived (webmail +// attachment-chips.tsx, 7b25d3a95). + +const inline = { blobId: 'b1', type: 'image/png', name: 'logo.png', disposition: 'inline', cid: 'logo' } as Attachment; +const file = { blobId: 'b2', type: 'application/pdf', name: 'invoice.pdf' } as Attachment; + +describe('list row paperclip and chips', () => { + it("follows the server's flag until the parts are in, then only real attachments", () => { + expect(showsPaperclip(true, undefined)).toBe(true); + expect(showsPaperclip(true, [inline])).toBe(false); + expect(showsPaperclip(true, [inline, file])).toBe(true); + expect(showsPaperclip(false, undefined)).toBe(false); + }); + + it('is wired into the row, which holds the chips line while they load', () => { + const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8'); + expect(list).toMatch(/showsPaperclip\(item\.hasAttachment, attachments\)/); + expect(list).toMatch(/pending=\{!!loadAttachments && item\.hasAttachment && !item\.attachments\}/); + const chips = fs.readFileSync(path.resolve(__dirname, '../../components/email/ListAttachmentChips.tsx'), 'utf8'); + expect(chips).toMatch(/if \(pending && attachments === undefined\) \{/); + }); +}); diff --git a/src/lib/__tests__/list-report-parts.test.ts b/src/lib/__tests__/list-report-parts.test.ts new file mode 100644 index 00000000..301d4f34 --- /dev/null +++ b/src/lib/__tests__/list-report-parts.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { isReportPart } from '../attachment-display'; +import { realAttachments } from '../list-attachments'; +import type { Attachment } from '../../api/types'; + +// A bounce's delivery status and a read receipt's disposition showed as +// attachment chips in the list (the reader already hid them), and the RFC +// 6533 `global-` forms counted as real files everywhere (webmail 8a09ba573). + +const part = (type: string, name: string): Attachment => ({ blobId: `b-${name}`, type, name, size: 1 } as Attachment); + +describe('report parts in list chips', () => { + it('counts the global- forms as report parts', () => { + expect(isReportPart('message/global-delivery-status')).toBe(true); + expect(isReportPart('message/global-disposition-notification')).toBe(true); + expect(isReportPart('Message/Delivery-Status; charset=utf-8')).toBe(true); + expect(isReportPart('message/rfc822')).toBe(false); + }); + + it('leaves report parts out of the list chips', () => { + const parts = [ + part('message/delivery-status', 'details.txt'), + part('message/global-disposition-notification', 'receipt'), + part('application/pdf', 'invoice.pdf'), + ]; + expect(realAttachments(parts).map((a) => a.name)).toEqual(['invoice.pdf']); + }); +}); diff --git a/src/lib/__tests__/list-view-key.test.ts b/src/lib/__tests__/list-view-key.test.ts new file mode 100644 index 00000000..c2358a36 --- /dev/null +++ b/src/lib/__tests__/list-view-key.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { listViewKey } from '../list-view-key'; + +// The list kept its scroll offset when the folder, tag or search changed, so +// the new list opened somewhere in the middle (webmail #1138). + +describe('list scroll position on a view switch (bulwarkmail/webmail#1138)', () => { + it('changes with the folder, the search and the tag, and only with them', () => { + const base = listViewKey('inbox', '', {}); + expect(listViewKey('inbox', '', {})).toBe(base); + expect(listViewKey('inbox', ' ', {})).toBe(base); + expect(listViewKey('archive', '', {})).not.toBe(base); + expect(listViewKey('inbox', 'invoice', {})).not.toBe(base); + expect(listViewKey('inbox', '', { keyword: '$label:red' })).not.toBe(base); + }); + + it('scrolls the list to the top when it changes', () => { + const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8'); + expect(list).toMatch(/ref=\{listRef\}/); + expect(list).toMatch(/listRef\.current\?\.scrollToOffset\(\{ offset: 0, animated: false \}\);\s*\}, \[viewKey\]\);/); + }); +}); diff --git a/src/lib/__tests__/mail-motion.test.ts b/src/lib/__tests__/mail-motion.test.ts new file mode 100644 index 00000000..13dad774 --- /dev/null +++ b/src/lib/__tests__/mail-motion.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { buildPauseAnimatedImagesScript, IS_ANIMATED_SOURCE, MAIL_REDUCED_MOTION_CSS, withReducedMotion } from '../mail-motion'; + +// The app follows the system's reduce motion, but mail kept its CSS +// animations and moving GIFs (webmail 24a13cc1f, bb7869798). + +const isAnimated = new Function(`${IS_ANIMATED_SOURCE}; return isAnimatedImageData;`)() as (b: Uint8Array) => boolean; + +function gif(frames: number): Uint8Array { + const head = [...'GIF89a'].map((ch) => ch.charCodeAt(0)); + const screen = [1, 0, 1, 0, 0, 0, 0]; // 1x1, no global colour table + const frame = [0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 1, 0, 0]; // descriptor, LZW size, one sub-block, end + const extension = [0x21, 0xf9, 4, 0, 0, 0, 0, 0]; // graphic control extension + const body: number[] = []; + for (let i = 0; i < frames; i++) body.push(...extension, ...frame); + return new Uint8Array([...head, ...screen, ...body, 0x3b]); +} + +function png(chunks: string[]): Uint8Array { + const out = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (const type of chunks) out.push(0, 0, 0, 0, ...[...type].map((ch) => ch.charCodeAt(0)), 0, 0, 0, 0); + return new Uint8Array(out); +} + +const base64 = (bytes: Uint8Array) => Buffer.from(bytes).toString('base64'); + +describe('reduce motion in mail', () => { + it('tells animated GIF, APNG and WebP from still images', () => { + expect(isAnimated(gif(2))).toBe(true); + expect(isAnimated(gif(1))).toBe(false); + expect(isAnimated(png(['IHDR', 'acTL', 'IDAT']))).toBe(true); + expect(isAnimated(png(['IHDR', 'IDAT']))).toBe(false); + const webp = (flags: number) => new Uint8Array([...'RIFF\0\0\0\0WEBPVP8X'].map((ch) => ch.charCodeAt(0)).concat([0, 0, 0, 0, flags, 0, 0, 0])); + expect(isAnimated(webp(0x02))).toBe(true); + expect(isAnimated(webp(0x00))).toBe(false); + }); + + it('stops CSS animations in the body', () => { + const html = withReducedMotion('

hi

'); + expect(html).toContain(``); + expect(MAIL_REDUCED_MOTION_CSS).toContain('animation-duration:0.01ms!important'); + }); + + it('swaps an animated image for a still frame with a Play button, and plays it on tap', () => { + const made: FakeEl[] = []; + let clickHandler: ((e: unknown) => void) | null = null; + class FakeEl { + attrs = new Map(); + children: FakeEl[] = []; + parentNode: FakeEl | null = null; + textContent = ''; + type = ''; + width = 0; + height = 0; + naturalWidth = 10; + complete = true; + style = { props: new Map(), width: '', setProperty(k: string, v: string) { this.props.set(k, v); }, removeProperty(k: string) { this.props.delete(k); } }; + constructor(public tagName: string) { made.push(this); } + get parentElement() { return this.parentNode; } + setAttribute(k: string, v: string) { this.attrs.set(k, v); } + getAttribute(k: string) { return this.attrs.get(k) ?? null; } + hasAttribute(k: string) { return this.attrs.has(k); } + appendChild(c: FakeEl) { c.parentNode?.children.splice(c.parentNode.children.indexOf(c), 1); c.parentNode = this; this.children.push(c); } + insertBefore(c: FakeEl, ref: FakeEl) { c.parentNode = this; this.children.splice(this.children.indexOf(ref), 0, c); } + getBoundingClientRect() { return { width: 10 }; } + getContext() { return { drawImage: () => undefined }; } + addEventListener() { /* loaded already */ } + querySelector(sel: string) { return this.children.find((c) => (sel.startsWith('canvas') ? c.tagName === 'CANVAS' : c.tagName === 'IMG')) ?? null; } + closest(sel: string) { return sel.startsWith('button') && this.tagName === 'BUTTON' ? this : null; } + } + const body = new FakeEl('BODY'); + const moving = new FakeEl('IMG'); + moving.setAttribute('src', `data:image/gif;base64,${base64(gif(3))}`); + const still = new FakeEl('IMG'); + still.setAttribute('src', `data:image/gif;base64,${base64(gif(1))}`); + body.appendChild(moving); + body.appendChild(still); + const document = { + body, + head: new FakeEl('HEAD'), + createElement: (tag: string) => new FakeEl(tag.toUpperCase()), + querySelectorAll: () => [moving, still], + addEventListener: (_: string, fn: (e: unknown) => void) => { clickHandler = fn; }, + }; + const atob = (s: string) => Buffer.from(s, 'base64').toString('binary'); + new Function('document', 'atob', 'URL', buildPauseAnimatedImagesScript({ play: 'Play animation', pause: 'Pause animation' }))(document, atob, URL); + + const wrap = body.children[0]; + expect(wrap.tagName).toBe('SPAN'); + const [button, canvas, img] = wrap.children; + expect(button.textContent).toBe('Play animation'); + expect(canvas.tagName).toBe('CANVAS'); + expect(img).toBe(moving); + expect(moving.style.props.get('display')).toBe('none'); + // The single-frame GIF is left alone. + expect(body.children[1]).toBe(still); + + const event = { target: button, preventDefault: () => undefined, stopImmediatePropagation: () => undefined }; + clickHandler!(event); + expect(button.textContent).toBe('Pause animation'); + expect(moving.style.props.has('display')).toBe(false); + }); + + it('is applied by the body view while motion is reduced', () => { + const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8'); + expect(view).toMatch(/const reduceMotion = !useShouldAnimate\(\);/); + expect(view).toMatch(/reduceMotion && prepared\.isHtml \? withReducedMotion\(prepared\.html\)/); + expect(view).toMatch(/prepared\.isHtml && reduceMotion \? buildPauseAnimatedImagesScript\(pauseLabels\)/); + }); +}); diff --git a/src/lib/__tests__/mail-reading.test.ts b/src/lib/__tests__/mail-reading.test.ts new file mode 100644 index 00000000..3d051a01 --- /dev/null +++ b/src/lib/__tests__/mail-reading.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { buildMailTextSizeScript, mailBaseFontPx, mailTextFactor } from '../mail-reading'; +import { buildBodyDocument } from '../email-body-document'; + +// The mail body was fixed at 14 px whatever the app's text size or the +// system font size said (webmail c07ba1a37 scales it). + +const base = { + key: 'k', emptyLabel: '-', blockRemoteImages: false, cidMap: {}, isDark: false, + messageSpacing: 'auto' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' }, +}; + +describe('mail text size', () => { + it('multiplies the app text size by the system font scale, within bounds', () => { + expect(mailTextFactor(1, 1)).toBe(1); + expect(mailTextFactor(1.14, 1.3)).toBe(1.48); + expect(mailTextFactor(1, 0)).toBe(1); + expect(mailTextFactor(2, 3)).toBe(3); + expect(mailBaseFontPx(1.5)).toBe(21); + }); + + it('sizes a plain-text body by the factor', () => { + expect(buildBodyDocument({ ...base, rawHtml: null, text: 'hello' }).html).toMatch(/font-size: 14px;/); + expect(buildBodyDocument({ ...base, rawHtml: null, text: 'hello', textFactor: 1.5 }).html).toMatch(/font-size: 21px;/); + }); + + it('scales every element of an HTML body once, line heights in px included', () => { + expect(buildMailTextSizeScript(1)).toBe(''); + const writes: Array<[string, string, string]> = []; + const attrs = new Set(); + const el = (tag: string, fontSize: string, lineHeight: string, inSvg = false) => ({ + tagName: tag, + computed: { fontSize, lineHeight }, + closest: (sel: string) => (sel === 'svg' && inSvg ? {} : null), + style: { setProperty: (prop: string, value: string) => writes.push([tag, prop, value]) }, + }); + const kids = [el('P', '16px', '24px'), el('SPAN', '12px', 'normal'), el('IMG', '14px', 'normal'), el('TEXT', '10px', 'normal', true)]; + const body = { + ...el('BODY', '14px', '22.4px'), + hasAttribute: (a: string) => attrs.has(a), + setAttribute: (a: string) => attrs.add(a), + querySelectorAll: () => kids, + }; + const window = { getComputedStyle: (e: { computed: unknown }) => e.computed }; + const run = () => new Function('document', 'window', buildMailTextSizeScript(1.5))({ body }, window); + run(); + expect(writes).toEqual([ + ['BODY', 'font-size', '21px'], ['BODY', 'line-height', '33.6px'], + ['P', 'font-size', '24px'], ['P', 'line-height', '36px'], + ['SPAN', 'font-size', '18px'], + ]); + // The body view injects its scripts again after load: no second pass. + run(); + expect(writes).toHaveLength(5); + }); + + it('is wired into the body view, with Android text zoom held at 100', () => { + const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8'); + expect(view).toMatch(/mailTextFactor\(dyn\.body\.fontSize \/ typography\.body\.fontSize, PixelRatio\.getFontScale\(\)\)/); + expect(view).toMatch(/buildMailTextSizeScript\(textFactor\)/); + expect(view).toMatch(/textZoom=\{100\}/); + }); +}); diff --git a/src/lib/__tests__/mail-removal-confirm.test.ts b/src/lib/__tests__/mail-removal-confirm.test.ts new file mode 100644 index 00000000..45859c81 --- /dev/null +++ b/src/lib/__tests__/mail-removal-confirm.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useSettingsStore } from '../../stores/settings-store'; +import { confirmMailRemoval, type AskFn } from '../mail-removal-confirm'; + +// "Confirm before deleting" and "Confirm before archiving" existed as +// settings but nothing asked: delete and archive went ahead from the open +// message, the selection bar and swipes (webmail lib/mail-removal-confirm.ts). + +const t = (key: string, fallback?: string, params?: Record) => + `${key}${params?.count !== undefined ? `:${params.count}` : ''}${fallback ? '' : ''}`; + +describe('confirmMailRemoval', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + useSettingsStore.getState().resetToDefaults(); + }); + + it('goes ahead without asking while the settings are off', async () => { + const ask = vi.fn(async () => false); + expect(await confirmMailRemoval('delete', 2, t, ask)).toBe(true); + expect(await confirmMailRemoval('archive', 2, t, ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + }); + + it('asks before a delete when "Confirm before deleting" is on, and stops on no', async () => { + useSettingsStore.setState({ confirmDelete: true }); + const no = vi.fn(async () => false); + expect(await confirmMailRemoval('delete', 3, t, no)).toBe(false); + expect(no).toHaveBeenCalledWith(expect.objectContaining({ + title: 'email_list.confirm_remove.delete_title:3', destructive: true, + })); + expect(await confirmMailRemoval('delete', 3, t, async () => true)).toBe(true); + // Archiving has its own switch. + expect(await confirmMailRemoval('archive', 3, t, no)).toBe(true); + expect(no).toHaveBeenCalledTimes(1); + }); + + it('asks before an archive when "Confirm before archiving" is on', async () => { + useSettingsStore.setState({ confirmArchive: true }); + const ask = vi.fn(async () => false); + expect(await confirmMailRemoval('archive', 1, t, ask)).toBe(false); + expect(ask).toHaveBeenCalledWith(expect.objectContaining({ title: 'email_list.confirm_remove.archive_title:1', destructive: false })); + expect(await confirmMailRemoval('archive', 0, t, ask)).toBe(true); + }); + + it('is asked from the open message, the selection bar and swipes, but not on top of the permanent delete question', () => { + const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8'); + const thread = src('screens/EmailThreadScreen.tsx'); + expect(thread).toMatch(/confirmMailRemoval\('delete', 1, t\)/); + expect(thread).toMatch(/confirmMailRemoval\('archive', 1, t\)/); + const list = src('screens/EmailListScreen.tsx'); + // deleteIds (swipe) and the selection bar's delete, each in the + // non-permanent branch only. + expect(list.match(/\} else if \(!\(await confirmMailRemoval\('delete', ids\.length, t\)\)\) \{/g)).toHaveLength(2); + expect(list).toMatch(/confirmMailRemoval\('archive', ids\.length, t\)/); + expect(list).toMatch(/confirmMailRemoval\('archive', selectedMessageIds\.length, t\)/); + const unified = src('screens/UnifiedInboxScreen.tsx'); + expect(unified).toMatch(/confirmMailRemoval\('archive', targets\.length, t\)/); + expect(unified).toMatch(/confirmMailRemoval\('delete', targets\.length, t\)/); + }); +}); diff --git a/src/lib/__tests__/mailbox-icon.test.ts b/src/lib/__tests__/mailbox-icon.test.ts index b9f85961..a61c7409 100644 --- a/src/lib/__tests__/mailbox-icon.test.ts +++ b/src/lib/__tests__/mailbox-icon.test.ts @@ -25,4 +25,11 @@ describe('mailboxIconKind', () => { expect(source).toContain('mailboxIconKind('); expect(source).not.toMatch(/lower\.includes\('(?:sent|inbox|draft|trash|junk|archive|star)'\)/); }); + + // The move sheet still guessed from the name (#1235 fixed only the sidebar). + it('is what the move sheet uses too, with no guess from the folder name', () => { + const source = readFileSync(path.join(process.cwd(), 'src/components/MoveSheet.tsx'), 'utf8'); + expect(source).toContain('mailboxIconKind('); + expect(source).not.toMatch(/\.includes\('(?:sent|inbox|draft|trash|deleted|junk|spam|archive)'\)/); + }); }); diff --git a/src/lib/__tests__/move-targets.test.ts b/src/lib/__tests__/move-targets.test.ts new file mode 100644 index 00000000..dcbf1135 --- /dev/null +++ b/src/lib/__tests__/move-targets.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { moveSheetCurrentId } from '../move-targets'; +import type { Mailbox } from '../../api/types'; + +// A search across folders keeps the open folder selected, and the move +// sheet greyed it out, so a hit from another folder couldn't be moved into +// it (webmail dafdda534, #1452). + +const box = (id: string, extra: Partial = {}): Mailbox => ({ id, name: id, totalEmails: 0, unreadEmails: 0, ...extra } as Mailbox); +const mailboxes = [box('inbox'), box('work'), box('g:raw', { originalId: 'raw', accountId: 'g' })]; + +describe('move sheet current folder (bulwarkmail/webmail#1452)', () => { + it('keeps the open folder out only when every row is already in it', () => { + expect(moveSheetCurrentId(mailboxes, 'inbox', [{ mailboxIds: { inbox: true } }])).toBe('inbox'); + expect(moveSheetCurrentId(mailboxes, 'inbox', [{ mailboxIds: { work: true }, jmapAccountId: 'me' }])).toBeNull(); + expect(moveSheetCurrentId(mailboxes, 'inbox', [{ mailboxIds: { inbox: true } }, { mailboxIds: { work: true } }])).toBeNull(); + expect(moveSheetCurrentId(mailboxes, null, [{ mailboxIds: { work: true } }])).toBeNull(); + }); + + it("matches a shared folder by its server id and account", () => { + expect(moveSheetCurrentId(mailboxes, 'g:raw', [{ mailboxIds: { raw: true }, jmapAccountId: 'g' }])).toBe('g:raw'); + expect(moveSheetCurrentId(mailboxes, 'g:raw', [{ mailboxIds: { raw: true }, jmapAccountId: 'other' }])).toBeNull(); + }); + + it('is what both move sheets of the list use', () => { + const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8'); + expect(list).toMatch(/currentMailboxId=\{pendingMoveCurrentId\}/); + expect(list).toMatch(/toId !== pendingMoveCurrentId/); + expect(list).toMatch(/currentMailboxId=\{moveSheetCurrentId\(mailboxes, currentMailboxId, selectedEmails\)\}/); + }); +}); diff --git a/src/lib/__tests__/new-tag.test.ts b/src/lib/__tests__/new-tag.test.ts new file mode 100644 index 00000000..893b25c2 --- /dev/null +++ b/src/lib/__tests__/new-tag.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { checkNewTag, composeKeywordId, MAX_KEYWORD_ID_LENGTH, newTagDefinition } from '../new-tag'; +import { DEFAULT_KEYWORDS } from '../../stores/keywords-store'; + +// The tag pickers listed existing tags only; a new one meant a trip to +// Settings (webmail tag-picker.tsx "New tag", 19676a14f). + +describe('new tag from the tag picker', () => { + it('makes the id the way the webmail does', () => { + expect(composeKeywordId(null, ' Client Acme / 2026! ')).toBe('client-acme-2026'); + expect(composeKeywordId('work', 'Clients')).toBe('work/clients'); + expect(composeKeywordId(null, '!!!')).toBe(''); + }); + + it('refuses an existing id and a name with no id or too long a one', () => { + expect(checkNewTag('Red', DEFAULT_KEYWORDS).error).toBe('tag_exists'); + expect(checkNewTag('???', DEFAULT_KEYWORDS).error).toBe('tag_name_invalid'); + expect(checkNewTag('x'.repeat(MAX_KEYWORD_ID_LENGTH + 1), DEFAULT_KEYWORDS).error).toBe('tag_name_invalid'); + expect(checkNewTag('', DEFAULT_KEYWORDS).error).toBeNull(); + expect(newTagDefinition('Red', DEFAULT_KEYWORDS)).toBeNull(); + }); + + it('gives the new tag the next colour of the palette row', () => { + // Seven default tags: index 7 of red, orange, amber, yellow, lime, green, teal, cyan… + expect(newTagDefinition(' Invoices ', DEFAULT_KEYWORDS)).toEqual({ id: 'invoices', label: 'Invoices', color: 'cyan' }); + expect(newTagDefinition('First', [])?.color).toBe('red'); + }); + + it('is offered in the list tag sheet and the message tag sheet, and applies the tag', () => { + const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8'); + expect(src('components/TagSheet.tsx')).toMatch(/ onToggle\(token, true\)\} \/>/); + expect(src('screens/EmailThreadScreen.tsx')).toMatch(//); + const row = src('components/NewTagRow.tsx'); + expect(row).toMatch(/addKeyword\(def\);/); + expect(row).toMatch(/onCreated\(keywordToken\(def\.id\)\);/); + }); +}); diff --git a/src/lib/__tests__/plain-text-theme-colors.test.ts b/src/lib/__tests__/plain-text-theme-colors.test.ts new file mode 100644 index 00000000..6ee24b3a --- /dev/null +++ b/src/lib/__tests__/plain-text-theme-colors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { buildBodyDocument } from '../email-body-document'; +import { BUILTIN_THEMES } from '../../theme/builtin-themes'; + +// A plain-text mail read in dark mode sat on a fixed #0a0a0a whatever the +// app's theme was (UX audit 2026-10, "Plain-text mail dark background"). + +const base = { + key: 'k', emptyLabel: '-', blockRemoteImages: false, cidMap: {}, rawHtml: null, text: 'hello', + messageSpacing: 'auto' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' }, +}; + +describe('plain-text mail colours', () => { + it("takes the dark theme's ground, text and link colours", () => { + const nord = { background: '#2e3440', text: '#eceff4', link: '#88c0d0' }; + const html = buildBodyDocument({ ...base, isDark: true, plainDarkColors: nord }).html; + expect(html).toContain('background: #2e3440;'); + expect(html).toContain('color: #eceff4;'); + expect(html).toContain('a { color: #88c0d0;'); + expect(html).not.toContain('#0a0a0a'); + }); + + it('keeps white in light mode and the old dark colours without a theme', () => { + const colors = { background: '#2e3440', text: '#eceff4', link: '#88c0d0' }; + expect(buildBodyDocument({ ...base, isDark: false, plainDarkColors: colors }).html).toContain('background: #ffffff;'); + expect(buildBodyDocument({ ...base, isDark: true }).html).toContain('background: #0a0a0a;'); + }); + + it('cannot close the style sheet with a theme colour', () => { + const html = buildBodyDocument({ + ...base, isDark: true, plainDarkColors: { background: 'red;}', text: '#fff', link: '#00f' }, + }).html; + expect(html).not.toContain('