Merge branch 'cu-reader' into catch-up-2026-11

# Conflicts:
#	docs/native-sync-2026-11.md
#	docs/parity/11-webmail-1.19.md
#	locales/rn/en.json
#	src/stores/settings-store.ts
This commit is contained in:
Linus Rath
2026-10-31 05:02:08 +01:00
86 changed files with 3473 additions and 234 deletions
+16
View File
@@ -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);
}
});
});
@@ -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 <ceo@bank.example>',
`To: ${USER}`,
`Subject: ${subject}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: <forged-${RUN}@live.test>`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=utf-8',
'',
'hello',
'',
].join('\r\n');
const { blobId } = await uploadBytes(new TextEncoder().encode(raw), 'message/rfc822');
const id = await importEmailBlob(blobId, scratch, {});
const page = await queryEmailPage(scratch, { limit: 10 });
const row = page.list.find((e) => e.id === id);
expect(row).toBeDefined();
expect(senderWarningOf(row!)?.status).toBe('failed');
// The 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);
});
});
+55 -3
View File
@@ -5,11 +5,13 @@ vi.mock('../jmap-client', () => ({
accountId: 'c',
request: vi.fn(),
getMaxCallsInRequest: vi.fn(() => 16),
currentSession: { capabilities: {} as Record<string, unknown> },
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<typeof vi.fn>;
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
@@ -30,6 +32,7 @@ function answer(totals: Record<string, { total: number; unread: number }>, 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, unknown>, 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, unknown>, 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, unknown>, 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');
});
});
@@ -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')];
+18 -5
View File
@@ -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 {
+64 -6
View File
@@ -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<string | undefined> = [undefined],
excluded: Record<string, string[]> = {},
): Promise<TagCount[]> {
if (tagIds.length === 0) return [];
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
const counts = new Map<string, TagCount>(tagIds.map((id) => [id, { id, total: 0, unread: 0 }]));
const queried: Array<string | undefined> = [];
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<string, { id: string; kind: 'total' | 'unread' }>();
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<TagCount[] | null> {
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;
}
}
+36
View File
@@ -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<Set<string>> {
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<string>();
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();
}
}
+10 -3
View File
@@ -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<string> = new Set([
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts',
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts', 'all',
]);
export interface UnifiedEmail extends Email {
+86 -7
View File
@@ -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 (
<View style={[styles.wrapper, fill && styles.wrapperFill]}>
{plainText !== null && (
<View style={styles.plainNote}>
<Text style={styles.bannerText}>
{t('email_viewer.shown_as_plain_text', 'This message is shown as plain text.')}
</Text>
{onShowFormatted && (
<Pressable onPress={onShowFormatted} hitSlop={8} accessibilityRole="button">
<Text style={styles.plainNoteLink}>{t('email_viewer.show_formatted', 'Show formatted')}</Text>
</Pressable>
)}
</View>
)}
{showBanner && (
<View style={styles.banner}>
<Text style={styles.bannerText}>
@@ -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',
+15 -10
View File
@@ -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<Record<MailboxIconKind, AppIcon>> = {
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({
</View>
<ScrollView>
{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).
+100
View File
@@ -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 (
<Pressable
onPress={() => setCreating(true)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
testID="tag-picker-new"
>
<Plus size={16} color={c.textMuted} />
<Text style={styles.label}>{t('email_viewer.new_tag', 'New tag')}</Text>
</Pressable>
);
}
return (
<View style={styles.form}>
<TextInput
autoFocus
value={name}
onChangeText={setName}
onSubmitEditing={create}
returnKeyType="done"
placeholder={t('email_viewer.new_tag_placeholder', 'Tag name')}
placeholderTextColor={c.textMuted}
accessibilityLabel={t('email_viewer.new_tag_placeholder', 'Tag name')}
style={[styles.input, errorText ? styles.inputError : null]}
/>
{errorText && <Text style={styles.error} accessibilityRole="alert">{errorText}</Text>}
</View>
);
}
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 },
});
}
+43 -12
View File
@@ -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 (
<View
style={containerStyle}
accessible
accessibilityLabel={`${primaryName || email || ''} (${senderWarning})`}
testID="sender-warning-avatar"
>
<ShieldAlert size={Math.round(size * 0.5)} color={c.error} />
</View>
);
}
if (content === 'photo' && photoSource) {
return (
<View style={containerStyle}>
<Image
@@ -96,7 +127,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
);
}
if (showFavicon && faviconDomain) {
if (content === 'favicon' && faviconDomain) {
return (
<View style={containerStyle}>
<Image
@@ -111,7 +142,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
return (
<View style={containerStyle}>
<Text style={[styles.initials, { fontSize }]}>{initials}</Text>
<Text style={[styles.initials, { fontSize, color: initialsColor(bgColor) }]}>{initials}</Text>
</View>
);
}
+14 -4
View File
@@ -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}
>
<Text style={styles.countUnread}>{unread}</Text>
</Pressable>
@@ -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 (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={350}
accessibilityRole={isAccountHeader ? 'header' : 'button'}
accessibilityLabel={accessibleName}
accessibilityState={isAccountHeader ? undefined : { selected: isSelected }}
style={({ pressed }) => [
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) => ({
+2
View File
@@ -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
</Pressable>
);
})}
<NewTagRow keywords={keywords} onCreated={(token) => onToggle(token, true)} />
</ScrollView>
</Animated.View>
</Modal>
@@ -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(/<Pressable\s+onPress=\{handleLongPress\}/);
expect(avatar).toMatch(/accessibilityRole="checkbox"/);
expect(avatar).toMatch(/accessibilityState=\{\{ checked: selected \}\}/);
expect(avatar).toMatch(/<SenderAvatar/);
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// "Plain Text Only" reads "Compose and read in plain text only", but only the
// composer honoured it: the reader always showed the HTML. The webmail reads
// HTML mail as plain text by default or per message (1e929b0a9). The
// conversion itself is tested in lib/__tests__/html-to-readable-text.test.ts;
// this checks the reader is wired to it.
const src = (p: string) => 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\}/);
});
});
@@ -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(/<Paperclip /);
expect(reader).toMatch(/const kind = attachmentKind\(item\.type, item\.name\);/);
expect(reader).toMatch(/<Icon size=\{14\} color=\{attachmentIconColor\(kind, c\)\} \/>/);
const list = src('email/ListAttachmentChips.tsx');
expect(list).toMatch(/ATTACHMENT_ICONS\[kind\]/);
expect(list).toMatch(/attachmentIconColor\(kind, c\)/);
});
});
@@ -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 \?/);
});
});
@@ -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 \}\)/);
});
});
+7 -2
View File
@@ -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
<View style={styles.row}>
{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 (
<Pressable
key={item.key}
@@ -241,7 +246,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
disabled={!!busyKey}
accessibilityLabel={item.name}
>
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : <Paperclip size={14} color={c.textMuted} />}
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon size={14} color={attachmentIconColor(kind, c)} />}
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
<Text style={styles.size}>{formatSize(item.size)}</Text>
<Download size={14} color={c.textMuted} />
+23 -28
View File
@@ -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<AttachmentKind, typeof FileIcon> = {
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 (
<View style={styles.row} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" testID="list-chips-placeholder">
<View style={[styles.chip, styles.placeholderChip]}>
<Text style={[styles.name, dyn.caption]}> </Text>
</View>
</View>
);
}
if (real.length === 0) return null;
const shown = real.slice(0, MAX_CHIPS);
@@ -94,7 +87,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
<View style={styles.row}>
{shown.map((a, i) => {
const kind = attachmentKind(a.type, a.name);
const Icon = ICONS[kind];
const Icon = ATTACHMENT_ICONS[kind];
return (
<Pressable
key={`${a.blobId}-${i}`}
@@ -106,7 +99,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
accessibilityRole="button"
accessibilityLabel={a.name}
>
<Icon size={12} color={iconColor(kind, c)} />
<Icon size={12} color={attachmentIconColor(kind, c)} />
<Text style={[styles.name, dyn.caption]} numberOfLines={1}>{shortAttachmentName(a.name ?? '')}</Text>
</Pressable>
);
@@ -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,
+8 -2
View File
@@ -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}
+9 -4
View File
@@ -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')}
>
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
<SenderAvatar
name={from?.name}
email={from?.email}
size={compact ? componentSizes.avatarSm : componentSizes.avatarMd}
senderWarning={verificationText?.label}
/>
</Pressable>
<View style={styles.info}>
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
@@ -253,7 +258,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
<View style={[styles.badge, { backgroundColor: c.primaryBg }]}>
<Text style={[styles.badgeText, { color: c.primary }]}>
{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 })}
</Text>
</View>
+5 -2
View File
@@ -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 (
<Pressable onPress={onToggleExpanded} style={({ pressed }) => [styles.collapsed, pressed && styles.pressed]}>
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} />
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} senderWarning={senderWarning} />
<View style={styles.collapsedInfo}>
<View style={styles.collapsedTop}>
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
@@ -56,7 +59,7 @@ export function ThreadMessageCard({
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
</View>
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(email.preview)}</Text>
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(cleanPreview(email.preview))}</Text>
</View>
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
</Pressable>
+28
View File
@@ -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<AttachmentKind, typeof FileIcon> = {
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;
}
}
@@ -0,0 +1,18 @@
import React from 'react';
import type { Email } from '../../api/types';
import { describeSenderVerification, senderWarningOf } from '../../lib/email-headers';
import { useLocaleStore } from '../../stores/locale-store';
/**
* The sender-check label of a message whose From the receiving server's
* checks don't back, the same verdict as the notice in the reader. Avatars
* show it instead of the claimed sender's favicon or photo (webmail
* use-sender-warning.ts, #1439). Undefined when the check passed, there are
* no results to judge by, or no message is given (Sent and Drafts rows show
* the recipient, whose avatar the check says nothing about).
*/
export function useSenderWarning(email: Pick<Email, 'headers' | 'from'> | undefined): string | undefined {
const t = useLocaleStore((s) => s.t);
const verification = React.useMemo(() => (email ? senderWarningOf(email) : null), [email]);
return verification ? describeSenderVerification(verification, t).label : undefined;
}
@@ -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\)/);
});
});
+25
View File
@@ -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 `report<U+202E>fdp.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');
});
});
+6
View File
@@ -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'));
+10
View File
@@ -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');
@@ -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('<p>Hello <b>Ann</b>,</p><p>first line<br>second line</p>'))
.toBe('Hello Ann,\n\nfirst line\nsecond line');
});
it('keeps link targets and image alt text', () => {
expect(htmlToReadableText('<p>Read <a href="https://ex.com/a">the report</a> <img src="x.png" alt="Logo"></p>'))
.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('<style>p{color:red}</style><a href="https://ex.com/">ex.com</a><script>x()</script>'))
.toBe('ex.com');
expect(htmlToReadableText('<a href="mailto:ann@ex.com?subject=Hi">Write to Ann</a>'))
.toBe('Write to Ann (ann@ex.com)');
});
it('turns layout tables into lines and lists into bullets', () => {
const html = '<table><tr><td>Name</td><td>Ann</td></tr><tr><td>City</td><td>Berlin</td></tr></table><ul><li>one</li><li>two</li></ul>';
expect(htmlToReadableText(html)).toBe('Name Ann\nCity Berlin\n\n• one\n• two');
});
it('skips hidden preheaders and images without alt text', () => {
expect(htmlToReadableText('<div style="display:none">preheader</div><img src="t.gif"><p>Body</p>')).toBe('Body');
});
it('reads a whole document: head, entities, comments and unclosed tags', () => {
const html = '<!DOCTYPE html><html><head><title>T</title><style>a{}</style></head><body>'
+ '<!-- tracking --><p>Fish &amp; chips&nbsp;&#8364;5<p>Next <a href="https://ex.com/?a=1&amp;b=2">deal</a>'
+ '<ul><li>one<li>two</ul><script>if (a < b) document.write("<p>x</p>")</script></body></html>';
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('<pre>a b\n c</pre>')).toBe('a b\n c');
expect(htmlToReadableText('<p>On Monday Bob wrote:</p><blockquote>hi</blockquote>')).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: '<p>Hi <b>there</b></p>', text: 'Hi there (text part)' })).toBe('Hi there (text part)');
expect(plainTextReading({ html: '<p>Hi <b>there</b></p>', text: null })).toBe('Hi there');
expect(plainTextReading({ html: '<p>Hi</p>', text: ' ' })).toBe('Hi');
expect(plainTextReading({ html: null, text: 'just text' })).toBeNull();
});
});
+29
View File
@@ -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\) \{/);
});
});
@@ -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']);
});
});
+24
View File
@@ -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\]\);/);
});
});
+112
View File
@@ -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('<html><body><p>hi</p></body></html>');
expect(html).toContain(`<style data-bulwark-reduced-motion>${MAIL_REDUCED_MOTION_CSS}</style></body>`);
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<string, string>();
children: FakeEl[] = [];
parentNode: FakeEl | null = null;
textContent = '';
type = '';
width = 0;
height = 0;
naturalWidth = 10;
complete = true;
style = { props: new Map<string, string>(), 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\)/);
});
});
+65
View File
@@ -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<string>();
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\}/);
});
});
@@ -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<string, string | number>) =>
`${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<AskFn>(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<AskFn>(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<AskFn>(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\)/);
});
});
+7
View File
@@ -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)'\)/);
});
});
+33
View File
@@ -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> = {}): 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\)\}/);
});
});
+39
View File
@@ -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(/<NewTagRow keywords=\{keywords\} onCreated=\{\(token\) => onToggle\(token, true\)\} \/>/);
expect(src('screens/EmailThreadScreen.tsx')).toMatch(/<NewTagRow keywords=\{keywords\} onCreated=\{onToggle\} \/>/);
const row = src('components/NewTagRow.tsx');
expect(row).toMatch(/addKeyword\(def\);/);
expect(row).toMatch(/onCreated\(keywordToken\(def\.id\)\);/);
});
});
@@ -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;}</style><script>x()</script>', text: '#fff', link: '#00f' },
}).html;
expect(html).not.toContain('</style><script>');
});
it('is fed from the app theme by the body view', () => {
expect(BUILTIN_THEMES.length).toBeGreaterThan(0);
const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8');
expect(view).toMatch(/background: c\.background, text: c\.text, link: c\.textLink/);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { cleanPreview, stripEmphasisMarkers, stripLeadingCss } from '../preview-text';
// Copied from the webmail (lib/__tests__/preview-leading-css.test.ts), plus
// the check that the list rows use it (#1136).
// Some servers build a message preview from its HTML without skipping the
// <style> element, so a marketing mail's preview starts with its style sheet.
describe('stripLeadingCss', () => {
it('drops a leading media query and keeps the text after it', () => {
const preview = '@media screen and (min-width:600px){.hide{display:none!important;overflow:hidden!important}.show{display:block!important}} Your parcel is on its way';
expect(stripLeadingCss(preview)).toBe('Your parcel is on its way');
});
it('drops several leading rules in a row', () => {
expect(stripLeadingCss('.a{color:red} #b p{margin:0} td, th {padding:0}Hello')).toBe('Hello');
});
it('drops a leading @import that ends without a block', () => {
expect(stripLeadingCss("@import url('x.css'); Weekly digest")).toBe('Weekly digest');
});
it('empties a preview that ends inside the style sheet', () => {
expect(stripLeadingCss('@media screen and (min-width:600px){.hide{display:none!important;max-height:0!import')).toBe('');
});
it('leaves ordinary text alone, braces and all', () => {
for (const text of [
'Hi Luca, the meeting is at {time} tomorrow',
'#1234 has been shipped',
'@Luca can you check the figures?',
'Order {A-17} confirmed',
'Price {EUR}: 10 per box',
'Meeting at {room',
'@media team, the brief is attached',
'@Page can you check?',
'@import the CSV, then send it back',
'Reminder: your appointment {date: 2026-10-01} is confirmed',
]) expect(stripLeadingCss(text)).toBe(text);
});
});
describe('cleanPreview', () => {
it('strips padding and a style sheet together, and treats nothing left as no preview', () => {
expect(cleanPreview('\u034f \u200b.x{color:red} Real text')).toBe('Real text');
expect(cleanPreview('.x{color:red} ...')).toBe('');
expect(cleanPreview(null)).toBe('');
});
});
// The preview comes from the text/plain part, which marks bold with asterisks
// where the HTML part uses <strong> (#1260).
describe('stripEmphasisMarkers', () => {
it('drops emphasis markers around words', () => {
expect(cleanPreview('*@Claude* mentioned you: please review')).toBe('@Claude mentioned you: please review');
expect(stripEmphasisMarkers('This is **really** important.')).toBe('This is really important.');
expect(stripEmphasisMarkers('See _the docs_ and __this__, (*now*)')).toBe('See the docs and this, (now)');
});
it('leaves asterisks and underscores inside words or between spaces alone', () => {
for (const text of [
'2 * 3 * 4 = 24',
'a*b*c and snake_case_name',
'rating: * * *',
'my_var_ is set',
'footnote* and *',
'***',
'__init__.py changed',
]) expect(stripEmphasisMarkers(text)).toBe(text);
});
});
describe('stripLeadingCss pseudo-classes', () => {
it('still drops a rule whose selector has a pseudo-class', () => {
expect(stripLeadingCss('a:hover, a:focus{color:red} Hello')).toBe('Hello');
});
});
describe('list rows', () => {
it('show the cleaned preview', () => {
const read = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8');
expect(read('screens/EmailListScreen.tsx')).toMatch(/singleLine\(cleanPreview\(item\.preview\)\)/);
expect(read('screens/UnifiedInboxScreen.tsx')).toMatch(/singleLine\(cleanPreview\(item\.preview\)\)/);
expect(read('components/email/ThreadMessageCard.tsx')).toMatch(/singleLine\(cleanPreview\(email\.preview\)\)/);
});
});
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import { buildQuoteCollapseScript, QUOTE_COLLAPSED_ATTR, QUOTE_TOGGLE_ATTR } from '../quote-collapse';
// The reader's quote-collapse script looked for an attributed <blockquote>
// by walking every text node from the start of the body once per top-level
// blockquote: quadratic, so a mail full of blockquotes froze the reader. The
// webmail does one walk (lib/quote-collapse.ts). These tests run the injected
// script against a small fake DOM that counts the tree-walker steps.
const SHOW_ELEMENT = 1;
const SHOW_TEXT = 4;
let steps = 0;
class FNode {
parentNode: FEl | null = null;
get parentElement(): FEl | null { return this.parentNode; }
}
class FText extends FNode {
nodeType = 3;
constructor(public nodeValue: string) { super(); }
}
class FEl extends FNode {
nodeType = 1;
tagName: string;
childNodes: FNode[] = [];
attrs = new Map<string, string>();
style = { display: '', cssText: '' };
title = '';
textContent = '';
constructor(tag: string) { super(); this.tagName = tag.toUpperCase(); }
append(...kids: Array<FNode | string>): this {
for (const kid of kids) {
const node = typeof kid === 'string' ? new FText(kid) : kid;
node.parentNode = this;
this.childNodes.push(node);
}
return this;
}
insertBefore(node: FNode, ref: FNode): void {
node.parentNode = this;
this.childNodes.splice(this.childNodes.indexOf(ref), 0, node);
}
get nextElementSibling(): FEl | null {
const sibs = this.parentNode?.childNodes ?? [];
for (let i = sibs.indexOf(this) + 1; i < sibs.length; i++) if (sibs[i] instanceof FEl) return sibs[i] as FEl;
return null;
}
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); }
removeAttribute(k: string) { this.attrs.delete(k); }
addEventListener() { /* not clicked here */ }
matches() { return false; }
// The fixtures hold no element the container or separator selectors match.
querySelector() { return null; }
querySelectorAll(tag: string): FEl[] {
return preorder(this).slice(1).filter((n): n is FEl => n instanceof FEl && n.tagName === tag.toUpperCase());
}
closest(tag: string): FEl | null {
for (let el: FEl | null = this; el; el = el.parentNode) if (el.tagName === tag.toUpperCase()) return el;
return null;
}
compareDocumentPosition(other: FNode): number {
for (let p = other.parentNode; p; p = p.parentNode) if (p === this) return 16 | 4; // contained by, following
const order = preorder(rootOf(this));
return order.indexOf(other) < order.indexOf(this) ? 2 : 4;
}
}
function rootOf(node: FNode): FNode {
let n = node;
while (n.parentNode) n = n.parentNode;
return n;
}
function preorder(root: FNode): FNode[] {
const out: FNode[] = [];
const visit = (n: FNode) => {
out.push(n);
if (n instanceof FEl) n.childNodes.forEach(visit);
};
visit(root);
return out;
}
function el(tag: string, ...kids: Array<FNode | string>): FEl {
return new FEl(tag).append(...kids);
}
function run(body: FEl): void {
const document = {
body,
createElement: (tag: string) => new FEl(tag),
createTreeWalker: (root: FEl, what: number) => {
const nodes = preorder(root).slice(1).filter((n) =>
(n instanceof FText && what & SHOW_TEXT) || (n instanceof FEl && what & SHOW_ELEMENT));
let i = 0;
return { nextNode: () => { steps++; return nodes[i++] ?? null; } };
},
};
const Node = {
TEXT_NODE: 3, DOCUMENT_POSITION_PRECEDING: 2, DOCUMENT_POSITION_FOLLOWING: 4, DOCUMENT_POSITION_CONTAINED_BY: 16,
};
const NodeFilter = { SHOW_ELEMENT, SHOW_TEXT };
const script = buildQuoteCollapseScript({ show: 'Show', hide: 'Hide' });
new Function('document', 'Node', 'NodeFilter', 'window', script)(document, Node, NodeFilter, {});
}
describe('quote collapse in HTML mail', () => {
it('finds the attributed blockquote in one walk, not one walk per blockquote', () => {
const n = 300;
const body = el('body');
for (let i = 0; i < n; i++) body.append(el('p', `line ${i}`), el('blockquote', `quote ${i}`));
steps = 0;
run(body);
// About 4n nodes in the body; walking from the start for each of the n
// blockquotes took about n² steps.
expect(steps).toBeLessThan(20 * n);
});
it('collapses the outermost blockquote after an attribution line', () => {
const inner = el('blockquote', 'older');
const outer = el('blockquote', el('p', 'old'), inner);
const first = el('blockquote', 'not attributed');
const body = el('body', el('p', 'a'), first, el('p', 'Thanks!'), el('p', 'On Monday, Bob wrote:'), outer);
run(body);
expect(outer.style.display).toBe('none');
expect(outer.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(true);
expect(first.style.display).toBe('');
expect(inner.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(false);
const button = body.childNodes[body.childNodes.indexOf(outer) - 1] as FEl;
expect(button.hasAttribute(QUOTE_TOGGLE_ATTR)).toBe(true);
});
it('leaves a blockquote alone when no attribution line precedes it', () => {
const quote = el('blockquote', 'quoted');
run(el('body', el('p', 'Hello there'), quote));
expect(quote.style.display).toBe('');
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { searchScopeFolders } from '../search-scope-folders';
import type { Mailbox } from '../../api/types';
// The search panel's Folder picker was a flat list cut at twelve folders, so
// deeper or later folders couldn't be searched on their own (webmail
// lib/search-scope-folders.ts lists them all as a tree).
const box = (id: string, parentId: string | null = null, role: string | null = null) =>
({ id, name: id, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, isShared: false } as unknown as Mailbox);
describe('search folder picker', () => {
it('lists every folder, each subfolder after its parent with its depth, without the open one', () => {
const boxes = [box('inbox', null, 'inbox'), ...Array.from({ length: 15 }, (_, i) => box(`f${String(i).padStart(2, '0')}`)), box('child', 'f03')];
const list = searchScopeFolders(boxes, 'inbox');
expect(list).toHaveLength(16);
const ids = list.map((m) => m.id);
expect(ids.indexOf('child')).toBe(ids.indexOf('f03') + 1);
expect(list.find((m) => m.id === 'child')?.depth).toBe(1);
expect(ids).not.toContain('inbox');
});
it('is what the panel shows, uncut', () => {
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
expect(list).not.toMatch(/\.slice\(0, 12\)/);
expect(list).toMatch(/searchScopeFolders\(scopedMailboxes, currentMailboxId\)\.map/);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { sortChoiceChanges, sortChoiceFor } from '../sort-choices';
// The list header only flipped newest/oldest; the order presets were three
// screens away in Settings > Layout (webmail e3f1ccce7 has a Sort menu).
describe('list header sort menu', () => {
it('names the order the open folder uses', () => {
expect(sortChoiceFor([], false)).toBe('newest');
expect(sortChoiceFor([], true)).toBe('oldest');
expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }], false)).toBe('unread');
expect(sortChoiceFor([{ criterion: 'from', direction: 'asc' }, { criterion: 'receivedAt', direction: 'desc' }], false)).toBe('sender');
expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }, { criterion: 'starred', direction: 'desc' }], false)).toBeNull();
});
it('writes the order setting, and widens an Inbox-only order when picked elsewhere', () => {
expect(sortChoiceChanges('largest', 'inbox', 'inbox')).toEqual({
messageListOrder: [{ criterion: 'size', direction: 'desc' }], mailSortAscending: false,
});
expect(sortChoiceChanges('starred', 'inbox', 'archive')?.messageListOrderScope).toBe('all');
expect(sortChoiceChanges('oldest', 'inbox', 'archive')).toEqual({ messageListOrder: [], mailSortAscending: true });
});
it('opens from the header button', () => {
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
expect(list).toMatch(/onPress=\{\(\) => setSortMenuOpen\(true\)\}/);
expect(list).toMatch(/sortChoiceChanges\(choice\.key, messageListOrderScope, currentRole\)/);
});
});
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', request: vi.fn(), hasDelayedSend: vi.fn(() => true) },
}));
import { jmapClient } from '../../api/jmap-client';
import { scheduledEmailIdsInThread } from '../../api/thread-scheduled';
import { isReplyableMember, quickReplyTarget } from '../thread-utils';
// Every card of a conversation offered Reply, and the quick reply answered
// the newest member even when that was the user's own draft or a reply
// waiting for its scheduled send (webmail 8b29254a6, #1404).
const msg = (id: string, keywords: Record<string, boolean> = {}) => ({ id, keywords });
const none = new Set<string>();
describe('replying in a conversation (bulwarkmail/webmail#1404)', () => {
it('does not offer Reply on drafts and scheduled messages', () => {
expect(isReplyableMember(msg('a'), none)).toBe(true);
expect(isReplyableMember(msg('d', { $draft: true }), none)).toBe(false);
expect(isReplyableMember(msg('s'), new Set(['s']))).toBe(false);
});
it('points the quick reply at the newest message that is neither', () => {
const conversation = [msg('1'), msg('2'), msg('3'), msg('draft', { $draft: true })];
expect(quickReplyTarget(conversation, none)?.id).toBe('3');
expect(quickReplyTarget(conversation, new Set(['3']))?.id).toBe('2');
expect(quickReplyTarget([msg('d', { $draft: true })], none)).toBeNull();
});
it('is wired into the conversation cards and the quick reply', () => {
const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailThreadScreen.tsx'), 'utf8');
expect(screen).toMatch(/replyable=\{!!full && isReplyableMember\(m, scheduledIds\)\}/);
expect(screen).toMatch(/const newest = quickReplyTarget\(/);
expect(screen).toMatch(/scheduledEmailIdsInThread\(threadId, jmapAccountId\)/);
});
});
describe('scheduledEmailIdsInThread', () => {
const request = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => request.mockReset());
it('keeps pending submissions whose send time is ahead', async () => {
const future = new Date(Date.now() + 3_600_000).toISOString();
const past = new Date(Date.now() - 60_000).toISOString();
request.mockResolvedValue({
methodResponses: [
['EmailSubmission/query', { ids: ['s1', 's2', 's3'] }, '0'],
['EmailSubmission/get', { list: [
{ emailId: 'e1', sendAt: future, undoStatus: 'pending' },
{ emailId: 'e2', sendAt: past, undoStatus: 'pending' },
{ emailId: 'e3', sendAt: future, undoStatus: 'canceled' },
] }, '1'],
],
});
expect([...await scheduledEmailIdsInThread('t1')]).toEqual(['e1']);
const [[calls]] = request.mock.calls;
expect(calls[0][1].filter).toEqual({ threadIds: ['t1'], undoStatus: 'pending' });
});
it('answers with nothing when the server refuses or the account cannot schedule', async () => {
request.mockResolvedValue({ methodResponses: [['error', { type: 'unsupportedFilter' }, '0'], ['error', {}, '1']] });
expect((await scheduledEmailIdsInThread('t1')).size).toBe(0);
request.mockImplementationOnce(async () => { throw new Error('offline'); });
expect((await scheduledEmailIdsInThread('t1')).size).toBe(0);
(jmapClient.hasDelayedSend as ReturnType<typeof vi.fn>).mockReturnValue(false);
request.mockClear();
expect((await scheduledEmailIdsInThread('t1')).size).toBe(0);
expect(request).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { viaIdentityNameShown } from '../email-headers';
// Mail sent as one of the user's identities read "Ann <ann@x>" with a
// "via ann@x" badge: the sender's address a second time (webmail
// email-identity-badge.tsx shows the identity's name, and only when it adds
// something).
describe('"via" badge', () => {
it("shows the identity's name only when it differs from what the sender line says", () => {
const from = { name: 'Ann Smith', email: 'ann@example.org' };
expect(viaIdentityNameShown({ name: 'Support', email: 'ann@example.org' }, from)).toBe(true);
expect(viaIdentityNameShown({ name: 'Ann Smith', email: 'ann@example.org' }, from)).toBe(false);
expect(viaIdentityNameShown({ name: 'ann@example.org', email: 'ann@example.org' }, from)).toBe(false);
expect(viaIdentityNameShown({ name: '', email: 'ann@example.org' }, from)).toBe(false);
});
it("names the identity, not its address, in the reader's badge", () => {
const header = fs.readFileSync(path.resolve(__dirname, '../../components/email/MessageHeader.tsx'), 'utf8');
expect(header).toMatch(/'via \{name\}', \{ name: viaIdentity\.identity\.name \}/);
expect(header).toMatch(/viaIdentityNameShown\(sentAs, from\)/);
});
});
+20 -4
View File
@@ -7,6 +7,7 @@ import type { Attachment, Email } from '../api/types';
import { findCalendarAttachment } from './calendar-invitation';
import { pickEmailBody, selectRenderableHtml } from './email-body';
import { extractCidRefs } from './email-html';
import { stripBidiControls } from './bidi';
const MIME_TYPE_LABELS: Record<string, string> = {
'application/pdf': 'Document.pdf',
@@ -31,7 +32,10 @@ const MIME_TYPE_LABELS: Record<string, string> = {
};
export function getAttachmentDisplayName(name: string | null | undefined, mimeType?: string): string {
if (name) return name;
// A direction override in a sender-chosen name can fake the file type
// (`report<U+202E>fdp.exe` shows as `reportexe.pdf`).
const shown = name ? stripBidiControls(name) : '';
if (shown) return shown;
if (mimeType) {
const base = mimeType.split(';')[0].trim().toLowerCase();
const label = MIME_TYPE_LABELS[base];
@@ -52,10 +56,22 @@ export function isCalendarMimeType(type?: string | null): boolean {
return CAL_MIME.has(type.split(';')[0].trim().toLowerCase());
}
/** Machine-readable report parts (MDN receipts, DSN bounces) - never real attachments. */
/**
* Machine-readable report parts: the delivery status of a bounce (RFC 3464),
* the disposition of a read receipt (RFC 8098) and their RFC 6533 UTF-8
* forms. A multipart/report needs them, but they are not files anyone
* attached, so no view lists them and a forward leaves them out (webmail
* lib/attachment-visibility.ts).
*/
const REPORT_PART_TYPES: ReadonlySet<string> = new Set([
'message/delivery-status',
'message/disposition-notification',
'message/global-delivery-status',
'message/global-disposition-notification',
]);
export function isReportPart(type?: string | null): boolean {
const base = (type || '').split(';')[0].trim().toLowerCase();
return base === 'message/disposition-notification' || base === 'message/delivery-status';
return REPORT_PART_TYPES.has((type || '').split(';')[0].trim().toLowerCase());
}
export function isRfc822Attachment(att: Pick<Attachment, 'name' | 'type'>): boolean {
+60
View File
@@ -12,6 +12,7 @@
import type { ContactCard } from '../api/types';
import { getContactPhotoRef, type ContactPhotoRef } from './contact-utils';
import { parseColor, relativeLuminance } from './event-colors';
// --- Account avatar -------------------------------------------------------
@@ -48,6 +49,44 @@ export function generateEmailAvatarColor(name: string, email?: string): string {
return `hsl(${hue}, 70%, 50%)`;
}
/** `hsl(h, s%, l%)` as RGB; null for anything else. */
function hslToRgb(color: string): { r: number; g: number; b: number; a: number } | null {
const m = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%/i.exec(color.trim());
if (!m) return null;
const h = (Number(m[1]) % 360) / 360;
const s = Number(m[2]) / 100;
const l = Number(m[3]) / 100;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
a: 1,
};
}
/**
* White or black initials, whichever contrasts more with the background.
* One of the two always reaches 4.58:1, so the initials pass 4.5:1 on every
* generated colour; white alone failed on yellows, greens and cyans
* (webmail components/ui/avatar.tsx initialsColor, 2e13accad).
*/
export function initialsColor(background: string): string {
const rgb = hslToRgb(background) ?? parseColor(background);
if (!rgb) return '#ffffff';
const l = relativeLuminance(rgb);
return 1.05 / (l + 0.05) >= (l + 0.05) / 0.05 ? '#ffffff' : '#000000';
}
/**
* Initials as the webmail's `Avatar` (components/ui/avatar.tsx) works them
* out: the first letters of the first and last name part, ignoring
@@ -196,3 +235,24 @@ export function getContactPhotoForEmail(
const key = email?.trim().toLowerCase();
return key ? getContactPhotoIndex(contacts).get(key) : undefined;
}
// --- What the avatar shows --------------------------------------------------
export type AvatarContent = 'warning' | 'photo' | 'favicon' | 'initials';
/**
* What a sender avatar shows, in the webmail `Avatar`'s order. A failed or
* unverified sender check comes first: a warning shield instead of any photo,
* favicon or initials, so a forged From doesn't get the real sender's logo or
* contact photo (webmail 605a3983b, #1439).
*/
export function avatarContent(options: {
senderWarning?: string | null;
hasPhoto: boolean;
showFavicon: boolean;
}): AvatarContent {
if (options.senderWarning) return 'warning';
if (options.hasPhoto) return 'photo';
if (options.showFavicon) return 'favicon';
return 'initials';
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Unicode directional formatting characters: the explicit embeddings,
* overrides and isolates (U+202A-202E, U+2066-2069) and the LRM/RLM marks.
* In a sender-controlled name they reorder what the reader sees, so
* `report<U+202E>fdp.exe` shows as `reportexe.pdf` (webmail lib/bidi.ts).
*/
const BIDI_CONTROLS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
/** `text` without directional formatting characters, for display. */
export function stripBidiControls(text: string): string {
return text.replace(BIDI_CONTROLS, '');
}
+17 -3
View File
@@ -6,9 +6,10 @@
import {
prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml,
type MessageSpacing, type PlainTextFont,
type MessageSpacing, type PlainTextFont, type PlainTextColors,
} from './email-html';
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
import { mailBaseFontPx } from './mail-reading';
export interface BodyDocumentInput {
/** The message (account and id); the sources below are compared as well. */
@@ -25,6 +26,13 @@ export interface BodyDocumentInput {
messageSpacing: MessageSpacing;
plainTextFont: PlainTextFont;
quoteLabels: QuoteCollapseLabels;
/**
* Factor for the text size (mailTextFactor): the base size of a plain-text
* body. An HTML body is scaled by an injected pass instead. Default 1.
*/
textFactor?: number;
/** The app theme's colours for a plain-text body in dark mode. */
plainDarkColors?: PlainTextColors;
}
export interface BodyDocument {
@@ -55,7 +63,12 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
}
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels);
return {
html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont }),
html: wrapPlainTextEmail(safe, {
isDark,
font: input.plainTextFont,
fontPx: mailBaseFontPx(input.textFactor ?? 1),
darkColors: input.plainDarkColors,
}),
applyInversion: false,
hasNativeDark: false,
blockedExternal: false,
@@ -83,7 +96,8 @@ function sameSources(a: BodyDocumentInput, b: BodyDocumentInput): boolean {
export function bodyDocument(input: BodyDocumentInput): BodyDocument {
const key = JSON.stringify([
input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel,
input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1,
input.plainDarkColors ?? null,
]);
const hit = documents.get(key);
if (hit && sameSources(hit.input, input)) {
+2 -1
View File
@@ -14,6 +14,7 @@ import {
} from './download-filename';
import { getClientCertAlias, secureFetch } from './client-cert';
import { sniffImageMime } from './email-html';
import { stripBidiControls } from './bidi';
import { handoffMimeType } from './file-preview';
const RFC822 = 'message/rfc822';
@@ -55,7 +56,7 @@ export function attachmentSaveName(email: Email, name: string | undefined, type:
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
const cleaned = stripBidiControls(name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
if (cleaned) return cleaned.slice(0, 120);
return `attachment.${fallbackExt}`;
}
+46 -1
View File
@@ -97,6 +97,21 @@ const SPF_SEVERITY: Record<SpfResult, number> = {
pass: 0,
};
/**
* Whether "via <identity>" on a message sent as one of the user's identities
* tells the reader anything: only when the identity's name differs from its
* address, the From address and the name already shown as the sender, so it
* no longer reads "ann@x via ann@x" (webmail email-identity-badge.tsx).
*/
export function viaIdentityNameShown(
identity: Pick<Identity, 'name' | 'email'>,
from: { name?: string | null; email?: string | null } | undefined,
): boolean {
const name = identity.name?.trim();
if (!name) return false;
return name !== identity.email && name !== from?.email && name !== from?.name;
}
/**
* Whether the authentication results indicate the visible From identity can't
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
@@ -244,6 +259,28 @@ export function senderVerificationOf(email: Pick<Email, 'headers' | 'from'>): Se
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
}
/**
* The list fetch asks for the Authentication-Results headers by this
* property, so list rows and collapsed conversation cards can judge the
* sender too (webmail 605a3983b, #1439). Stalwart answers header properties
* from the stored headers, not the message blob.
*/
export const LIST_AUTH_RESULTS_PROPERTY = 'header:Authentication-Results:all';
/**
* The sender check for a message as the list or the reader holds it: from
* the full headers when they are there, else from the list property. Null
* when the checks back the From or there is nothing to judge by.
*/
export function senderWarningOf(email: Pick<Email, 'headers' | 'from'>): SenderVerification | null {
if (email.headers) return senderVerificationOf(email);
const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY];
if (!Array.isArray(raw)) return null;
const values = raw.filter((value): value is string => typeof value === 'string');
if (values.length === 0) return null;
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
}
interface ResInfo {
method: string;
result: string;
@@ -323,6 +360,12 @@ function parseResinfos(header: string): ResInfo[] {
.filter((info): info is ResInfo => info !== null);
}
/** The domain of a DKIM identity (`header.i`): `@domain` or `user@domain`. */
function identityDomain(identity: string | undefined): string | undefined {
const at = identity?.lastIndexOf('@') ?? -1;
return identity && at >= 0 && at < identity.length - 1 ? identity.slice(at + 1) : undefined;
}
const DMARC_SEVERITY: Record<string, number> = {
fail: 3,
permerror: 2,
@@ -394,7 +437,9 @@ export function parseAuthenticationResults(headers: string | readonly string[]):
.filter((info) => info.method === 'dkim')
.map((info) => ({
result: info.result as DkimResult,
domain: info.props['header.d'],
// Some receivers name the signature only by its identity (`header.i`,
// `@domain` or `user@domain`, RFC 8601 2.7.1); its domain is the signer's.
domain: info.props['header.d'] ?? identityDomain(info.props['header.i']),
selector: info.props['header.s'],
}));
if (dkimResults.length > 0) {
+22 -6
View File
@@ -953,16 +953,32 @@ export type PlainTextFont = 'sans' | 'mono';
// WebView. Mirrors the webmail's plain-text render path: app font by default
// (monospace on request, #830), `white-space: pre-wrap`, native dark theme
// (no filter inversion), and a dark-mode-appropriate link colour.
/** The app theme's colours for a plain-text body read in dark mode. */
export interface PlainTextColors {
background: string;
text: string;
link: string;
}
/** A theme colour for a style sheet: nothing that could end the declaration or the element. */
function cssColor(value: string, fallback: string): string {
const cleaned = value.replace(/[<>{};\\]/g, '').trim();
return cleaned || fallback;
}
export function wrapPlainTextEmail(
innerHtml: string,
options: { isDark?: boolean; font?: PlainTextFont } = {},
options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number; darkColors?: PlainTextColors } = {},
): string {
const { isDark = true, font = 'sans' } = options;
const { isDark = true, font = 'sans', fontPx = 14, darkColors } = options;
const cleaned = stripDangerousTags(innerHtml);
const bg = isDark ? '#0a0a0a' : '#ffffff';
const fg = isDark ? '#fafafa' : '#1a1a1a';
const link = isDark ? '#60a5fa' : '#1a73e8';
// In dark mode the body takes the app theme's ground, text and link colours
// when they are given (a built-in dark theme isn't #0a0a0a); light mode
// stays on white like HTML mail.
const bg = isDark ? cssColor(darkColors?.background ?? '', '#0a0a0a') : '#ffffff';
const fg = isDark ? cssColor(darkColors?.text ?? '', '#fafafa') : '#1a1a1a';
const link = isDark ? cssColor(darkColors?.link ?? '', '#60a5fa') : '#1a73e8';
const fontFamily = font === 'mono'
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
@@ -976,7 +992,7 @@ body {
margin: 0;
padding: 16px;
font-family: ${fontFamily};
font-size: 14px;
font-size: ${fontPx}px;
line-height: 1.6;
color: ${fg};
white-space: pre-wrap;
+20
View File
@@ -0,0 +1,20 @@
import type { Mailbox } from '../api/types';
/** Roles a folder link may name instead of an id (webmail lib/deep-links.ts). */
const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'junk']);
/**
* The folder a `mail/folder/<ref>` link names: an exact id first, then a role
* alias (the own account's folder before a shared one's). Null when nothing in
* the loaded folders matches, a link to a folder that was deleted or lives in
* another account (webmail resolveFolderRef).
*/
export function resolveFolderRef(ref: string, mailboxes: readonly Mailbox[]): string | null {
if (mailboxes.some((m) => m.id === ref)) return ref;
if (ALIASED_ROLES.has(ref)) {
const byRole = mailboxes.find((m) => m.role === ref && !m.isShared)
?? mailboxes.find((m) => m.role === ref);
return byRole?.id ?? null;
}
return null;
}
+246
View File
@@ -0,0 +1,246 @@
/**
* HTML mail as plain text, for "Plain Text Only" reading and the reader's
* per-message "Show as plain text" when a message has no text/plain part of
* its own. Port of the webmail's lib/html-to-readable-text.ts.
*
* Layout tables become lines, headings and paragraphs keep a blank line
* around them, list items get a bullet, images are replaced by their alt text
* and a link keeps its target in brackets after its text, so nothing the
* formatted mail offered is lost.
*
* The webmail walks a DOMParser tree; React Native has no DOM, so this walks
* the tags in order with a stack of open elements and closes the elements an
* HTML parser would close on its own (a paragraph before a block, a list item
* before the next one, table cells and rows).
*/
import { decodeEntities } from './verification-code';
const BLOCK = new Set([
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'CENTER', 'DD', 'DETAILS', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION',
'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE',
'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TFOOT', 'THEAD', 'TR', 'UL',
]);
/** Blocks that get a blank line before and after, not just a line break. */
const PARAGRAPH = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'UL', 'OL', 'TABLE', 'HR']);
const SKIP = new Set(['HEAD', 'STYLE', 'SCRIPT', 'TEMPLATE', 'NOSCRIPT', 'TITLE', 'META', 'LINK', 'SVG', 'OBJECT', 'IFRAME']);
/** Elements whose content is raw text up to their end tag. */
const RAW_TEXT = new Set(['SCRIPT', 'STYLE', 'TITLE', 'TEXTAREA', 'XMP', 'NOSCRIPT', 'IFRAME', 'NOEMBED', 'NOFRAMES']);
const VOID = new Set(['AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
/** Link targets worth writing out: web, mail and phone links. */
function shownHref(href: string | null): string | null {
if (!href) return null;
const trimmed = href.trim();
if (/^mailto:/i.test(trimmed)) return trimmed.slice(7).split('?')[0] || null;
if (/^(https?:|tel:)/i.test(trimmed)) return trimmed;
return null;
}
function normalize(text: string): string {
return text.replace(/[ \t\f\v\r\n\xa0]+/g, ' ');
}
/** Same address once with and once without the scheme or a trailing slash. */
function sameTarget(text: string, href: string): boolean {
const strip = (s: string) => s.trim().toLowerCase().replace(/^(https?:\/\/|mailto:|tel:)/, '').replace(/\/$/, '');
return strip(text) === strip(href);
}
const TOKEN = /<!--[\s\S]*?(?:-->|$)|<![^>]*>?|<\?[^>]*>?|<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
const ATTR = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
function parseAttrs(raw: string): Map<string, string> {
const attrs = new Map<string, string>();
ATTR.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ATTR.exec(raw)) !== null) {
const name = m[1].toLowerCase();
if (!attrs.has(name)) attrs.set(name, decodeEntities(m[2] ?? m[3] ?? m[4] ?? ''));
}
return attrs;
}
interface Frame {
tag: string;
/** Inside a skipped or hidden element: nothing is written. */
skip: boolean;
pre: boolean;
listDepth: number;
/** out.length when the element opened, for a link's text. */
start: number;
href: string | null;
}
/** Open elements an element closes when it starts, as an HTML parser does. */
function closesOnOpen(open: string, tag: string): boolean {
if (open === 'P') return BLOCK.has(tag);
if (open === 'LI') return tag === 'LI';
if (open === 'DT' || open === 'DD') return tag === 'DT' || tag === 'DD';
if (open === 'TD' || open === 'TH') return tag === 'TD' || tag === 'TH' || tag === 'TR';
if (open === 'TR') return tag === 'TR';
return false;
}
/** Elements that stop the search for an element to close on open. */
const SCOPE = new Set(['UL', 'OL', 'TABLE', 'DL', 'BLOCKQUOTE', 'DIV', 'TD', 'TH']);
export function htmlToReadableText(html: string): string {
if (!html) return '';
const out: string[] = [];
// Pending line breaks, so runs of empty blocks collapse to one blank line.
let pendingBreaks = 0;
let atLineStart = true;
const breakLine = (count: number) => {
if (out.length === 0) return;
pendingBreaks = Math.max(pendingBreaks, count);
};
const write = (text: string) => {
if (!text) return;
if (pendingBreaks > 0) {
out.push('\n'.repeat(pendingBreaks));
pendingBreaks = 0;
atLineStart = true;
}
if (atLineStart) text = text.replace(/^ +/, '');
if (!text) return;
out.push(text);
atLineStart = text.endsWith('\n');
};
const stack: Frame[] = [];
const top = (): Frame | undefined => stack[stack.length - 1];
const close = (frame: Frame) => {
if (frame.skip) return;
const { tag } = frame;
if (tag === 'A') {
const text = out.slice(frame.start).join('').trim();
if (frame.href && !sameTarget(text, frame.href)) write(text ? ` (${frame.href})` : frame.href);
}
if (BLOCK.has(tag)) {
const gap = PARAGRAPH.has(tag) && !(tag === 'UL' || tag === 'OL') ? 2 : 1;
breakLine(tag === 'LI' ? 1 : gap);
}
};
const open = (tag: string, attrs: Map<string, string>) => {
// Close what this element implicitly ends (a <p> before a block, the
// previous <li>, table cells and rows).
for (let i = stack.length - 1; i >= 0; i--) {
const frameTag = stack[i].tag;
if (closesOnOpen(frameTag, tag)) {
while (stack.length > i) close(stack.pop()!);
break;
}
if (SCOPE.has(frameTag)) break;
}
const parent = top();
const inherited = parent?.skip ?? false;
const hidden = attrs.has('hidden') || /display\s*:\s*none/i.test(attrs.get('style') ?? '');
const skip = inherited || SKIP.has(tag) || hidden;
const pre = parent?.pre ?? false;
const listDepth = parent?.listDepth ?? 0;
if (VOID.has(tag)) {
if (skip) return;
if (tag === 'BR') {
out.push('\n');
pendingBreaks = 0;
atLineStart = true;
} else if (tag === 'IMG' || (tag === 'INPUT' && attrs.get('type') === 'image')) {
const alt = normalize(attrs.get('alt') ?? '').trim();
if (alt) write(`[${alt}]`);
} else if (tag === 'HR') {
breakLine(2);
write('---');
breakLine(2);
}
return;
}
if (!skip) {
const isBlock = BLOCK.has(tag);
const gap = PARAGRAPH.has(tag) && !(tag === 'UL' || tag === 'OL') ? 2 : 1;
if (isBlock) breakLine(listDepth > 0 && (tag === 'UL' || tag === 'OL') ? 1 : gap);
if (tag === 'LI') write(`${' '.repeat(Math.max(0, listDepth - 1))}• `);
if (tag === 'TD' || tag === 'TH') {
if (!atLineStart && pendingBreaks === 0) write(' ');
}
if (tag === 'BLOCKQUOTE') write('> ');
}
stack.push({
tag,
skip,
pre: pre || tag === 'PRE',
listDepth: tag === 'UL' || tag === 'OL' ? listDepth + 1 : listDepth,
start: out.length,
href: tag === 'A' ? shownHref(attrs.get('href') ?? null) : null,
});
};
const end = (tag: string) => {
let i = stack.length - 1;
while (i >= 0 && stack[i].tag !== tag) i--;
if (i < 0) return; // a stray end tag
while (stack.length > i) close(stack.pop()!);
};
const text = (raw: string) => {
const frame = top();
if (frame?.skip || !raw) return;
const value = decodeEntities(raw);
write(frame?.pre ? value : normalize(value));
};
TOKEN.lastIndex = 0;
let lower: string | undefined;
let pos = 0;
let m: RegExpExecArray | null;
while ((m = TOKEN.exec(html)) !== null) {
if (m.index > pos) text(html.slice(pos, m.index));
pos = TOKEN.lastIndex;
if (m[2] === undefined) continue; // comment, doctype, processing instruction
const tag = m[2].toUpperCase();
if (m[1]) {
end(tag);
continue;
}
open(tag, parseAttrs(m[3]));
const selfClosing = /\/\s*$/.test(m[3]);
if (selfClosing && !VOID.has(tag)) end(tag);
if (RAW_TEXT.has(tag) && !selfClosing) {
// Raw text runs to the end tag: a "<" inside a script is not a tag.
lower ??= html.toLowerCase();
const endAt = lower.indexOf(`</${tag.toLowerCase()}`, pos);
const stop = endAt < 0 ? html.length : endAt;
if (!SKIP.has(tag)) text(html.slice(pos, stop));
pos = stop;
TOKEN.lastIndex = stop;
}
}
if (pos < html.length) text(html.slice(pos));
while (stack.length > 0) close(stack.pop()!);
return out
.join('')
.split('\n')
.map((line) => line.replace(/[ \t]+$/g, ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/**
* The text the reader shows for a message read as plain text: its own
* text/plain part when it has one, else its HTML turned into text (webmail
* email-viewer.tsx, 1e929b0a9). Null when there is no HTML to replace: a
* plain-text message reads as it is.
*/
export function plainTextReading(body: { html: string | null; text: string | null }): string | null {
if (!body.html) return null;
if (body.text?.trim()) return body.text;
return htmlToReadableText(body.html);
}
+15 -2
View File
@@ -1,4 +1,5 @@
import type { Attachment, Email } from '../api/types';
import { isReportPart } from './attachment-display';
/**
* Attachment chips for message-list rows (webmail 0e1c34cd, #1089).
@@ -14,11 +15,23 @@ import type { Attachment, Email } from '../api/types';
/**
* The parts the sender actually attached. Anything marked inline or
* referenced by a Content-ID (signature logos, spacers, tracking pixels) is
* dropped, as are parts without a filename, which can't be labelled or saved.
* dropped, as are parts without a filename, which can't be labelled or saved,
* and the report parts of a bounce or a read receipt.
*/
export function realAttachments(attachments?: readonly Attachment[] | null): Attachment[] {
if (!attachments?.length) return [];
return attachments.filter((a) => a.disposition !== 'inline' && !a.cid && !!a.name);
return attachments.filter((a) => a.disposition !== 'inline' && !a.cid && !!a.name && !isReportPart(a.type));
}
/**
* Whether a list row shows the paperclip: the server's `hasAttachment` until
* the row's parts are loaded, then only when a real attachment is among them,
* so a message with nothing but inline images loses it (webmail
* components/email/attachment-chips.tsx).
*/
export function showsPaperclip(hasAttachment: boolean, attachments: readonly Attachment[] | undefined): boolean {
if (attachments === undefined) return hasAttachment;
return realAttachments(attachments).length > 0;
}
/**
+13
View File
@@ -0,0 +1,13 @@
/**
* What the message list shows, as one string: the folder, the search and the
* filters (a tag view is a keyword filter). The list scrolls back to the top
* when it changes, so another folder, tag or search doesn't open at the
* offset the last one was left at (webmail #1138).
*/
export function listViewKey(
currentMailboxId: string | null,
searchQuery: string,
filters: object,
): string {
return JSON.stringify([currentMailboxId, searchQuery.trim(), filters]);
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Reduce motion inside a message body (webmail lib/mail-reading.ts
* MAIL_REDUCED_MOTION_CSS, 24a13cc1f, and lib/animated-images.ts,
* bb7869798): CSS animations and transitions stop, and each animated GIF,
* WebP or APNG shows a still frame with a Play button.
*
* The page allows no script of its own, so the pause runs as a script the
* body view injects; the frame check is written out as a string because the
* app's JS engine (Hermes) can't hand a function's source to the page.
*/
/** Stops CSS animations and transitions in a mail. */
export const MAIL_REDUCED_MOTION_CSS = '*,*::before,*::after{animation-duration:0.01ms!important;animation-iteration-count:1!important;animation-delay:0s!important;transition-duration:0.01ms!important;transition-delay:0s!important;scroll-behavior:auto!important}';
/** Puts the reduce-motion style sheet into a built body document. */
export function withReducedMotion(html: string): string {
const style = `<style data-bulwark-reduced-motion>${MAIL_REDUCED_MOTION_CSS}</style>`;
const at = html.lastIndexOf('</body>');
return at < 0 ? html + style : html.slice(0, at) + style + html.slice(at);
}
/**
* `function isAnimatedImageData(bytes)`: whether image bytes hold more than
* one frame (GIF image descriptors, an APNG acTL chunk, the WebP animation
* flag). Shared by the page script and the tests.
*/
export const IS_ANIMATED_SOURCE = `function isAnimatedImageData(bytes) {
if (!bytes || bytes.length < 12) return false;
function ascii(start, length) {
var s = '';
for (var k = start; k < start + length && k < bytes.length; k++) s += String.fromCharCode(bytes[k]);
return s;
}
var i;
if (ascii(0, 4) === 'GIF8') {
i = 13;
var flags = bytes[10];
if (flags & 0x80) i += 3 * (1 << ((flags & 0x07) + 1));
var frames = 0;
var skipSubBlocks = function () {
while (i < bytes.length && bytes[i] !== 0) i += bytes[i] + 1;
i += 1;
};
while (i < bytes.length) {
var block = bytes[i];
if (block === 0x2c) {
frames += 1;
if (frames > 1) return true;
var local = bytes[i + 9];
i += 10;
if (local & 0x80) i += 3 * (1 << ((local & 0x07) + 1));
i += 1;
skipSubBlocks();
} else if (block === 0x21) {
i += 2;
skipSubBlocks();
} else {
break;
}
}
return false;
}
if (bytes[0] === 0x89 && ascii(1, 3) === 'PNG') {
i = 8;
while (i + 8 <= bytes.length) {
var length = ((bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]) >>> 0;
var type = ascii(i + 4, 4);
if (type === 'acTL') return true;
if (type === 'IDAT' || type === 'IEND') return false;
i += 12 + length;
}
return false;
}
if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
if (ascii(12, 4) === 'VP8X' && bytes.length > 20) return (bytes[20] & 0x02) !== 0;
return false;
}
return false;
}`;
export interface PauseLabels {
play: string;
pause: string;
}
const TOGGLE_ATTR = 'data-bulwark-animation';
const STILL_ATTR = 'data-bulwark-still';
const PAUSE_STYLE = `[${TOGGLE_ATTR}-wrap]{position:relative;display:inline-block;max-width:100%;line-height:0}`
+ `canvas[${STILL_ATTR}]{max-width:100%;height:auto}`
+ `button[${TOGGLE_ATTR}]{position:absolute;top:6px;inset-inline-start:6px;z-index:1;min-width:32px;min-height:32px;padding:4px 10px;border:2px solid #fff;border-radius:999px;background:rgba(0,0,0,.75);color:#fff;font:600 13px/1.2 system-ui,sans-serif;animation:none!important;transition:none!important}`;
/**
* Script that freezes the animated images of the body: each one is swapped
* for a canvas holding its first frame, with a Play button on top; Play shows
* the moving image again and the button becomes Pause. Inline images (data:
* URIs) are checked by their bytes; a remote image only by its name (.gif,
* .apng), since the page can't fetch it. Runs once per document.
*/
export function buildPauseAnimatedImagesScript(labels: PauseLabels): string {
const cfg = JSON.stringify({ play: labels.play, pause: labels.pause, style: PAUSE_STYLE });
return `
(function () {
if (document.__rnPauseImages || !document.body) return;
document.__rnPauseImages = true;
var C = ${cfg};
var T = '${TOGGLE_ATTR}';
var S = '${STILL_ATTR}';
${IS_ANIMATED_SOURCE}
function dataBytes(src) {
var m = /^data:[^,]*;base64,([\\s\\S]*)$/i.exec(src);
if (!m) return null;
try {
var bin = atob(m[1].replace(/\\s+/g, ''));
var out = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
} catch (e) { return null; }
}
function mayMove(src) {
if (/^data:/i.test(src)) return isAnimatedImageData(dataBytes(src));
if (/^https?:/i.test(src)) {
try { return /\\.(gif|apng)$/i.test(new URL(src).pathname); } catch (e) { return false; }
}
return false;
}
function draw(img, canvas) {
var ctx = canvas.getContext && canvas.getContext('2d');
if (!ctx) return false;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
try { ctx.drawImage(img, 0, 0); } catch (e) { return false; }
return true;
}
function freeze(img) {
if (img.hasAttribute(S) || img.naturalWidth <= 1) return;
img.setAttribute(S, '');
if (!mayMove(img.currentSrc || img.getAttribute('src') || '')) return;
var canvas = document.createElement('canvas');
if (!draw(img, canvas)) return;
var width = img.getBoundingClientRect().width;
canvas.setAttribute(S, '');
['class', 'style'].forEach(function (a) { var v = img.getAttribute(a); if (v) canvas.setAttribute(a, v); });
if (width > 0) canvas.style.width = width + 'px';
var alt = (img.getAttribute('alt') || '').trim();
if (alt) { canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', alt); }
else canvas.setAttribute('aria-hidden', 'true');
var wrap = document.createElement('span');
wrap.setAttribute(T + '-wrap', '');
var button = document.createElement('button');
button.type = 'button';
button.setAttribute(T, 'paused');
button.textContent = C.play;
img.parentNode && img.parentNode.insertBefore(wrap, img);
wrap.appendChild(button);
wrap.appendChild(canvas);
wrap.appendChild(img);
img.style.setProperty('display', 'none', 'important');
}
var style = document.createElement('style');
style.textContent = C.style;
(document.head || document.body).appendChild(style);
// Capture phase, so a button inside a linked image doesn't open the link.
document.addEventListener('click', function (event) {
var button = event.target && event.target.closest ? event.target.closest('button[' + T + ']') : null;
if (!button) return;
event.preventDefault();
event.stopImmediatePropagation();
var wrap = button.parentElement;
var canvas = wrap && wrap.querySelector('canvas[' + S + ']');
var img = wrap && wrap.querySelector('img');
if (!canvas || !img) return;
if (button.getAttribute(T) === 'paused') {
img.style.removeProperty('display');
canvas.style.setProperty('display', 'none');
button.setAttribute(T, 'playing');
button.textContent = C.pause;
} else {
draw(img, canvas);
canvas.style.removeProperty('display');
img.style.setProperty('display', 'none', 'important');
button.setAttribute(T, 'paused');
button.textContent = C.play;
}
}, true);
var imgs = document.querySelectorAll('img');
for (var n = 0; n < imgs.length; n++) {
(function (img) {
if (img.complete) freeze(img);
else img.addEventListener('load', function () { freeze(img); }, { once: true });
})(imgs[n]);
}
})();
true;
`;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* How the app's text size reaches a message body (webmail lib/mail-reading.ts,
* c07ba1a37). The body renders in a WebView the app's styles don't reach, so
* the size is carried in: as the base font size of a plain-text body, and as
* a pass over the computed styles of an HTML body once it has laid out.
*/
/** Size of body text in a message, in CSS px at 100 %: what mail used to be fixed at. */
export const MAIL_BASE_FONT_PX = 14;
const MIN_FACTOR = 0.5;
const MAX_FACTOR = 3;
/**
* The factor message text is drawn at: the app's text size times the
* system font scale (Android font size, iOS Dynamic Type). Android's WebView
* would apply the system scale on its own; the body view turns that off
* (`textZoom` 100) so both platforms scale the same way, once.
*/
export function mailTextFactor(appFactor: number, systemFontScale: number): number {
const app = appFactor > 0 ? appFactor : 1;
const system = systemFontScale > 0 ? systemFontScale : 1;
const factor = Math.round(app * system * 100) / 100;
return Math.min(MAX_FACTOR, Math.max(MIN_FACTOR, factor));
}
/** The base font size of a body at `factor`, in CSS px. */
export function mailBaseFontPx(factor: number): number {
return Math.round(MAIL_BASE_FONT_PX * factor * 100) / 100;
}
const DONE_ATTR = 'data-bulwark-text-size';
/**
* Script injected into an HTML body that scales its text by `factor`: reads
* every element's computed font size first and writes the scaled sizes after,
* pinning each element to its own px size so a child sized in em doesn't grow
* twice. A line height in px grows by the same ratio. Runs once per document
* (the body view injects its scripts again after load). Text size works on
* the text rather than zooming the whole mail, so a fixed-width newsletter
* keeps its width and its text wraps inside it. Empty at factor 1.
*/
export function buildMailTextSizeScript(factor: number): string {
if (!(factor > 0) || Math.abs(factor - 1) < 0.005) return '';
return `
(function () {
var F = ${JSON.stringify(factor)};
var body = document.body;
if (!body || body.hasAttribute('${DONE_ATTR}')) return;
body.setAttribute('${DONE_ATTR}', '');
var NO_TEXT = { IMG: 1, BR: 1, HR: 1, WBR: 1, PICTURE: 1, SOURCE: 1, VIDEO: 1, AUDIO: 1, CANVAS: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, STYLE: 1, SCRIPT: 1, TEMPLATE: 1 };
var els = [body];
var all = body.querySelectorAll('*');
for (var i = 0; i < all.length; i++) {
var el = all[i];
if (NO_TEXT[el.tagName] || el.closest('svg')) continue;
els.push(el);
}
var sizes = [];
for (var j = 0; j < els.length; j++) {
var cs = window.getComputedStyle(els[j]);
var font = parseFloat(cs.fontSize);
if (!(font > 0)) continue;
var lh = /px$/.test(cs.lineHeight) ? parseFloat(cs.lineHeight) : null;
sizes.push([els[j], font * F, lh === null ? null : lh * F]);
}
for (var k = 0; k < sizes.length; k++) {
var s = sizes[k];
s[0].style.setProperty('font-size', Math.round(s[1] * 100) / 100 + 'px', 'important');
if (s[2] !== null) s[0].style.setProperty('line-height', Math.round(s[2] * 100) / 100 + 'px', 'important');
}
if (window.__rnReport) setTimeout(window.__rnReport, 0);
})();
true;
`;
}
+56
View File
@@ -0,0 +1,56 @@
import { Alert } from 'react-native';
import { useSettingsStore } from '../stores/settings-store';
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
/** Shows the question; resolves true when the user went ahead. */
export type AskFn = (question: { title: string; message: string; confirmText: string; destructive: boolean; cancelText: string }) => Promise<boolean>;
const alertAsk: AskFn = ({ title, message, confirmText, destructive, cancelText }) => new Promise((resolve) => {
Alert.alert(
title,
message,
[
{ text: cancelText, style: 'cancel', onPress: () => resolve(false) },
{ text: confirmText, style: destructive ? 'destructive' : 'default', onPress: () => resolve(true) },
],
{ cancelable: true, onDismiss: () => resolve(false) },
);
});
/**
* Asks before messages are deleted or archived when the user turned on
* "Confirm before deleting" or "Confirm before archiving" (webmail
* lib/mail-removal-confirm.ts). Applies to the open message, the selection
* bar and swipes. Resolves true when the action may go ahead: the setting is
* off, or the user confirmed.
*
* A permanent delete has its own, stronger question; ask this one only when
* no other confirmation is shown, so nobody is asked twice.
*/
export async function confirmMailRemoval(
kind: 'delete' | 'archive',
count: number,
t: Translate,
ask: AskFn = alertAsk,
): Promise<boolean> {
const settings = useSettingsStore.getState();
if (!(kind === 'delete' ? settings.confirmDelete : settings.confirmArchive)) return true;
if (count <= 0) return true;
const params = { count };
return kind === 'delete'
? ask({
title: t('email_list.confirm_remove.delete_title', '{count, plural, one {Delete this email?} other {Delete # emails?}}', params),
message: t('email_list.confirm_remove.delete_message', '{count, plural, one {It moves to the trash.} other {They move to the trash.}}', params),
confirmText: t('email_list.confirm_remove.delete', 'Delete', params),
cancelText: t('common.cancel', 'Cancel'),
destructive: true,
})
: ask({
title: t('email_list.confirm_remove.archive_title', '{count, plural, one {Archive this email?} other {Archive # emails?}}', params),
message: t('email_list.confirm_remove.archive_message', '{count, plural, one {It moves to your archive.} other {They move to your archive.}}', params),
confirmText: t('email_list.confirm_remove.archive', 'Archive', params),
cancelText: t('common.cancel', 'Cancel'),
destructive: false,
});
}
+26
View File
@@ -0,0 +1,26 @@
import type { Email, Mailbox } from '../api/types';
/** Whether a list row sits in `mailbox` (a shared folder's id carries its account). */
function rowInMailbox(row: Pick<Email, 'mailboxIds' | 'jmapAccountId'>, mailbox: Mailbox): boolean {
if (row.jmapAccountId && mailbox.accountId && row.jmapAccountId !== mailbox.accountId) return false;
const raw = mailbox.originalId ?? mailbox.id;
return !!row.mailboxIds?.[raw] || !!row.mailboxIds?.[mailbox.id];
}
/**
* The folder the move sheet marks as current (checked, not pickable). The
* open folder only when every message being moved is already in it: a search
* across folders lists messages from other folders while the open one stays
* selected, and those can be moved into it (webmail lib/move-targets.ts,
* #1452). Null when it can be picked.
*/
export function moveSheetCurrentId(
mailboxes: readonly Mailbox[],
currentMailboxId: string | null,
rows: ReadonlyArray<Pick<Email, 'mailboxIds' | 'jmapAccountId'>>,
): string | null {
if (!currentMailboxId) return null;
const current = mailboxes.find((m) => m.id === currentMailboxId);
if (!current || rows.length === 0) return currentMailboxId;
return rows.every((row) => rowInMailbox(row, current)) ? currentMailboxId : null;
}
+54
View File
@@ -0,0 +1,54 @@
import type { KeywordColor, KeywordDef } from '../stores/keywords-store';
// "New tag" in the tag pickers (webmail components/email/tag-picker.tsx,
// 19676a14f; ids from lib/keyword-nesting.ts).
/**
* Longest keyword Stalwart keeps (1.0 refuses a longer one, 0.16 cuts it),
* less the `$label:` prefix.
*/
export const MAX_KEYWORD_ID_LENGTH = 128 - '$label:'.length;
/** Palette row 1 of the webmail's tag colours: a new tag takes the next one. */
export const NEW_TAG_PALETTE: KeywordColor[] = [
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray',
];
/**
* One level of a tag id from a display name: lowercase, everything outside
* `[a-z0-9_-]` folded to a single dash (a slash typed into the name is part
* of the name, not a level).
*/
export function normalizeKeywordLevel(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/** The id a tag named `name` gets under `parentId` (null = top level). */
export function composeKeywordId(parentId: string | null, name: string): string {
const level = normalizeKeywordLevel(name);
if (!parentId || !level) return level;
return `${parentId}/${level}`;
}
export type NewTagError = 'tag_exists' | 'tag_name_invalid';
/** What a typed tag name would make: its id, and why it can't be made (null = it can). */
export function checkNewTag(name: string, keywords: readonly Pick<KeywordDef, 'id'>[]): { id: string; error: NewTagError | null } {
const id = composeKeywordId(null, name);
if (!name.trim()) return { id, error: null };
if (keywords.some((k) => k.id === id)) return { id, error: 'tag_exists' };
if (!id || id.length > MAX_KEYWORD_ID_LENGTH) return { id, error: 'tag_name_invalid' };
return { id, error: null };
}
/** The definition for a new tag, or null when the name can't make one. */
export function newTagDefinition(name: string, keywords: readonly Pick<KeywordDef, 'id'>[]): KeywordDef | null {
const { id, error } = checkNewTag(name, keywords);
if (!name.trim() || error) return null;
return { id, label: name.trim(), color: NEW_TAG_PALETTE[keywords.length % NEW_TAG_PALETTE.length] };
}
+88
View File
@@ -0,0 +1,88 @@
// Message previews ready to show (webmail lib/utils.ts cleanPreview, #1136,
// #1260): some servers build the preview from an HTML body without skipping
// its <style>, so a marketing mail's preview began with CSS; preheaders pad
// with invisible characters; text/plain emphasis markers ("*bold*") show.
// Copied from the webmail as it is.
// Marketing emails pad the preheader with whitespace, format chars (soft
// hyphens, zero-width chars, BOM, directional marks) and combining marks
// (e.g. U+034F) to push real content past the preview window. Strip them all.
// \p{Cf} = Format, \p{Mn} = combining marks; \s covers figure space, NBSP, etc.
const LEADING_INVISIBLE_RE = /^[\s\p{Cf}\p{Mn}]+/u;
// After stripping, a server-side truncation indicator like "..." may be all
// that's left. Treat that as no preview so callers can fall back.
const ONLY_PUNCTUATION_RE = /^[.\u2026\s]+$/;
export function stripInvisibleLeading(text: string): string {
const stripped = text.replace(LEADING_INVISIBLE_RE, '');
if (ONLY_PUNCTUATION_RE.test(stripped)) return '';
return stripped;
}
// A style sheet at the start of the text: an at-rule, or a selector opening a
// rule. Plain prose does not start like this - "@" or "#" followed by a word
// and then, before any sentence ends, a "{".
const DECLARATIONS_RE = /^\s*(?:[-\w]+\s*:|[.#@a-z*][^{}]*\{)/i;
// At-rule names are matched case-sensitively: style sheets write them in lower
// case, while "@Page …" at the start of a preview is a mention. A selector's
// colon is a pseudo-class ("a:hover"), never followed by a space as in
// "Reminder: your appointment {date: …}".
const AT_RULE_RE = /^@(?:media|font-face|import|supports|keyframes|charset|page)\b/;
const SELECTOR_RE = /^[.#]?[a-z*](?:[\w\-.#,>~+*\s[\]="']|:(?!\s))*\{/i;
/**
* Drops a style sheet from the start of a text preview.
*
* Some servers build the preview from an HTML body without skipping its
* <style> element, so a marketing mail's preview can begin with
* "@media screen and (min-width:600px){.hide{display:none!important;…" and
* never reach the text. Whole leading rules are removed; a preview that ends
* inside one was nothing but CSS and becomes empty, so callers fall back to
* their "no preview" text.
*/
export function stripLeadingCss(text: string): string {
let rest = text.trimStart();
for (let guard = 0; guard < 100 && (AT_RULE_RE.test(rest) || SELECTOR_RE.test(rest)); guard++) {
const brace = rest.indexOf('{');
const semicolon = rest.indexOf(';');
// `@import url(...);` and `@charset "x";` end without a block.
if (semicolon >= 0 && (brace < 0 || semicolon < brace) && /^@(?:import|charset)\b/.test(rest)) {
rest = rest.slice(semicolon + 1).trimStart();
continue;
}
// An at-rule word with no block after it ("@media team, …", "@page 3 …")
// starts prose, not a style sheet.
if (brace < 0) return rest;
let depth = 0;
let end = -1;
for (let i = brace; i < rest.length; i++) {
if (rest[i] === '{') depth++;
else if (rest[i] === '}' && --depth === 0) { end = i; break; }
}
if (end < 0) return rest.startsWith('@') || DECLARATIONS_RE.test(rest.slice(brace + 1)) ? '' : rest;
// A rule's block holds declarations ("color: red") or further rules; a
// brace in prose ("at {time} tomorrow") holds neither and ends the strip.
if (!rest.startsWith('@') && !DECLARATIONS_RE.test(rest.slice(brace + 1, end))) return rest;
rest = rest.slice(end + 1).trimStart();
}
return rest;
}
// Plain-text emphasis: "*bold*", "**bold**", "_italic_", "__bold__". The
// markers sit at word edges, so "2 * 3 * 4", "a*b*c", "snake_case_name" and
// "__init__.py" are left alone.
const EMPHASIS_RE = /(^|[\s([{"'])(\*\*|__|\*|_)([^\s*_]|[^\s*_].*?[^\s])\2(?=$|[\s)\]}"']|[.,;:!?](?!\w))/g;
/**
* Drops plain-text emphasis markers. The server builds the preview from the
* text/plain part, where senders like GitHub write "*@user* mentioned you"
* while the HTML part shows it in bold.
*/
export function stripEmphasisMarkers(text: string): string {
return text.replace(EMPHASIS_RE, '$1$3');
}
/** A message preview ready to show: no leading padding, style sheet or emphasis markers. */
export function cleanPreview(text: string | null | undefined): string {
return stripEmphasisMarkers(stripInvisibleLeading(stripLeadingCss(stripInvisibleLeading(text ?? ''))));
}
+14 -16
View File
@@ -92,24 +92,22 @@ export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string {
}
return false;
}
function precedingTextEndsWithColon(el) {
var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
// One walk in document order, remembering the last non-blank text: a
// blockquote is reached before its own text, so that is the text preceding
// it. Walking from the start again per blockquote took quadratic time and
// froze the reader on a mail full of blockquotes (webmail quote-collapse.ts).
function findAttributedBlockquote() {
var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
var last = null;
for (var node = walker.nextNode(); node; node = walker.nextNode()) {
var rel = el.compareDocumentPosition(node);
if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue;
if (!(rel & Node.DOCUMENT_POSITION_PRECEDING)) break;
var text = (node.nodeValue || '').trim();
if (text !== '') last = text;
}
return last !== null && /:$/.test(last);
}
function findAttributedBlockquote() {
var bqs = body.querySelectorAll('blockquote');
for (var i = 0; i < bqs.length; i++) {
var bq = bqs[i];
if (bq.parentElement && bq.parentElement.closest('blockquote')) continue;
if (precedingTextEndsWithColon(bq)) return bq;
if (node.nodeType === Node.TEXT_NODE) {
var text = (node.nodeValue || '').trim();
if (text !== '') last = text;
continue;
}
if (node.tagName !== 'BLOCKQUOTE') continue;
if (node.parentElement && node.parentElement.closest('blockquote')) continue;
if (last !== null && /:$/.test(last)) return node;
}
return null;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Mailbox } from '../api/types';
import { buildMailboxTree, flattenVisible, type MailboxNode } from './mailbox-tree';
/**
* The folders the search panel's Folder picker offers: every folder (it was
* cut at twelve), in sidebar order with each subfolder right after its parent
* and its `depth` for indenting (webmail lib/search-scope-folders.ts). The
* open folder is left out: "This folder" picks it. A shared account's
* grouping row is no folder and is left out too.
*/
export function searchScopeFolders(mailboxes: Mailbox[], currentMailboxId: string | null): MailboxNode[] {
const tree = buildMailboxTree(mailboxes);
const all = new Set<string>();
const collect = (nodes: MailboxNode[]) => {
for (const n of nodes) {
all.add(n.id);
collect(n.children);
}
};
collect(tree);
return flattenVisible(tree, all).filter((n) => !n.isAccountNode && n.id !== currentMailboxId);
}
+58
View File
@@ -0,0 +1,58 @@
import { isDefaultOrder, sanitizeSortLevels, type SortLevel } from './message-list-order';
// The Sort menu of the list header (webmail components/email/mail-list-menus.tsx,
// e3f1ccce7). It writes the "Message list order" setting, so Settings agrees.
// The app keeps oldest/newest in its own `mailSortAscending` switch (the
// header button flipped it), so Newest and Oldest set that and clear the order.
export type SortChoiceKey = 'newest' | 'oldest' | 'unread' | 'starred' | 'sender' | 'subject' | 'largest';
export const SORT_CHOICES: ReadonlyArray<{ key: SortChoiceKey; levels: SortLevel[]; ascending: boolean }> = [
{ key: 'newest', levels: [], ascending: false },
{ key: 'oldest', levels: [], ascending: true },
{ key: 'unread', levels: [{ criterion: 'unread', direction: 'desc' }], ascending: false },
{ key: 'starred', levels: [{ criterion: 'starred', direction: 'desc' }], ascending: false },
{ key: 'sender', levels: [{ criterion: 'from', direction: 'asc' }], ascending: false },
{ key: 'subject', levels: [{ criterion: 'subject', direction: 'asc' }], ascending: false },
{ key: 'largest', levels: [{ criterion: 'size', direction: 'desc' }], ascending: false },
];
/** The choice the open folder's order is, or null for an order only Settings makes. */
export function sortChoiceFor(levels: SortLevel[], ascending: boolean): SortChoiceKey | null {
const last = levels[levels.length - 1];
const core = levels.length > 1 && last.criterion === 'receivedAt' && last.direction === 'desc'
? levels.slice(0, -1)
: levels;
if (isDefaultOrder(core)) return ascending ? 'oldest' : 'newest';
if (core.length !== 1 || ascending) return null;
const match = SORT_CHOICES.find((choice) =>
choice.levels.length === 1
&& choice.levels[0].criterion === core[0].criterion
&& choice.levels[0].direction === core[0].direction);
return match?.key ?? null;
}
export interface SortChoiceChanges {
messageListOrder: SortLevel[];
mailSortAscending: boolean;
/** Set when the order only applied to the Inbox and was picked in another folder. */
messageListOrderScope?: 'all';
}
/**
* The settings a pick writes. Picked outside the Inbox while the order applies
* to the Inbox only, it applies to all folders, or the pick would do nothing.
*/
export function sortChoiceChanges(
key: SortChoiceKey,
scope: 'inbox' | 'all',
folderRole: string | null | undefined,
): SortChoiceChanges | null {
const choice = SORT_CHOICES.find((c) => c.key === key);
if (!choice) return null;
return {
messageListOrder: sanitizeSortLevels(choice.levels.map((level) => ({ ...level }))),
mailSortAscending: choice.ascending,
...(scope === 'inbox' && folderRole !== 'inbox' && choice.levels.length > 0 ? { messageListOrderScope: 'all' as const } : {}),
};
}
+27
View File
@@ -178,3 +178,30 @@ export function expandThreadSelection(
}
return out;
}
/**
* Whether a conversation member can be replied to: not the user's own draft
* and not a message waiting for its scheduled send (webmail 8b29254a6,
* #1404).
*/
export function isReplyableMember(
email: Pick<Email, 'id' | 'keywords'>,
scheduledIds: ReadonlySet<string>,
): boolean {
return !email.keywords?.$draft && !scheduledIds.has(email.id);
}
/**
* The message the quick reply answers: the newest member that can be
* replied to, so it never quotes the user's own draft or scheduled reply.
* Null when there is none.
*/
export function quickReplyTarget<T extends Pick<Email, 'id' | 'keywords'>>(
members: readonly T[],
scheduledIds: ReadonlySet<string>,
): T | null {
for (let i = members.length - 1; i >= 0; i--) {
if (isReplyableMember(members[i], scheduledIds)) return members[i];
}
return null;
}
+1 -1
View File
@@ -363,7 +363,7 @@ const LETTER_ENTITIES: Record<string, string> = {
upsih: 'ϒ', upsilon: 'υ', uuml: 'ü', xi: 'ξ', yacute: 'ý', yuml: 'ÿ', zeta: 'ζ',
};
function decodeEntities(text: string): string {
export function decodeEntities(text: string): string {
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (m, ref: string) => {
if (ref[0] === '#') {
const cp = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { handleDeepLink, parseDeepLink } from '../linking';
import { usePendingMailFolder } from '../pending-mail-folder';
import { resolveFolderRef } from '../../lib/folder-ref';
import type { Mailbox } from '../../api/types';
// A `mail/folder/<ref>` link opened only the Mail tab; the folder it named
// was dropped (file 10, "/mail/folder/<ref> deep link opens only the tab").
const box = (id: string, role: string | null, isShared = false) => ({ id, name: id, role, isShared } as unknown as Mailbox);
describe('mail/folder deep link', () => {
it('parks the folder for the mail list', async () => {
const link = parseDeepLink('bulwarkmobile://mail/folder/archive');
const navigation = { isReady: () => true, navigate: vi.fn() };
expect(await handleDeepLink(link!, { navigation: navigation as never, resolveThreadId: async () => null })).toBe(true);
expect(usePendingMailFolder.getState().consume()).toBe('archive');
expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Mail' });
});
it('resolves an id or a role, the own folder first, and nothing for an unknown one', () => {
const boxes = [box('g:in', 'inbox', true), box('in', 'inbox'), box('f1', null)];
expect(resolveFolderRef('f1', boxes)).toBe('f1');
expect(resolveFolderRef('inbox', boxes)).toBe('in');
expect(resolveFolderRef('trash', boxes)).toBeNull();
expect(resolveFolderRef('gone', boxes)).toBeNull();
});
it('is opened by the mail list', () => {
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
expect(list).toMatch(/const id = ref === null \? null : resolveFolderRef\(ref, mailboxes\);/);
expect(list).toMatch(/if \(id !== currentMailboxId\) void selectMailbox\(id\);/);
});
});
+3
View File
@@ -9,6 +9,7 @@ import type { RootStackParamList } from './types';
import { setPendingSettingsTab } from './pending-settings-tab';
import { setPendingCalendarOpen } from './pending-calendar-open';
import { setPendingMailSearch } from './pending-mail-search';
import { setPendingMailFolder } from './pending-mail-folder';
import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link';
import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
import { MAX_ACCOUNTS } from '../lib/account-utils';
@@ -283,6 +284,8 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
return true;
case 'folder':
// The mail list opens the folder once its folders are loaded.
setPendingMailFolder(link.ref);
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
return true;
case 'calendar':
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand';
// A `mail/folder/<ref>` deep link arrives before the mail list is mounted or
// its folders are loaded. The reference is parked here and consumed by
// EmailListScreen, which opens the folder once it can resolve it.
interface PendingMailFolderState {
ref: string | null;
set: (ref: string | null) => void;
consume: () => string | null;
}
export const usePendingMailFolder = create<PendingMailFolderState>((set, get) => ({
ref: null,
set: (ref) => set({ ref }),
consume: () => {
const ref = get().ref;
if (ref !== null) set({ ref: null });
return ref;
},
}));
export function setPendingMailFolder(ref: string | null): void {
usePendingMailFolder.getState().set(ref);
}
+157 -32
View File
@@ -7,8 +7,11 @@ import {
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide,
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder,
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check,
} from '../components/icons';
import { ActionSheet } from '../components/email/ActionSheet';
import { SORT_CHOICES, sortChoiceChanges, sortChoiceFor, type SortChoiceKey } from '../lib/sort-choices';
import { orderForMailbox, sanitizeSortLevels } from '../lib/message-list-order';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, componentSizes, mix, type ThemePalette } from '../theme/tokens';
@@ -21,9 +24,11 @@ import { MoveSheet } from '../components/MoveSheet';
import { TagSheet } from '../components/TagSheet';
import { OfflineBanner } from '../components/OfflineBanner';
import {
ListAttachmentChips, ListAttachmentOpener, useListRowAttachments,
ListAttachmentChips, ListAttachmentOpener, useListAttachments, useListRowAttachments,
} from '../components/email/ListAttachmentChips';
import { showsPaperclip } from '../lib/list-attachments';
import { VerificationCodeChip, useListVerificationCode } from '../components/email/VerificationCodeChip';
import { useSenderWarning } from '../components/email/use-sender-warning';
import type { LoadListAttachments } from '../lib/list-attachments';
import { useNetworkStore } from '../stores/network-store';
import {
@@ -47,6 +52,7 @@ import { toast } from '../stores/toast-store';
import { getContactDisplayName } from '../lib/contact-utils';
import { formatListDate } from '../lib/date-format';
import { singleLine } from '../lib/single-line';
import { cleanPreview } from '../lib/preview-text';
import {
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
} from '../lib/mailbox-tree';
@@ -55,10 +61,16 @@ import {
collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, rowKeyOf,
} from '../lib/thread-utils';
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
import { moveSheetCurrentId } from '../lib/move-targets';
import { listViewKey } from '../lib/list-view-key';
import { searchScopeFolders } from '../lib/search-scope-folders';
import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
import type { RootStackParamList } from '../navigation/types';
import { usePendingMailSearch } from '../navigation/pending-mail-search';
import { usePendingMailFolder } from '../navigation/pending-mail-folder';
import { resolveFolderRef } from '../lib/folder-ref';
import type { Attachment, Email } from '../api/types';
import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
@@ -100,6 +112,19 @@ function isPinned(email: Email): boolean {
return !!email.keywords?.$pinned;
}
// Written out so the string harvest finds every key.
function sortChoiceLabel(key: SortChoiceKey, t: (key: string, fallback?: string) => string): string {
switch (key) {
case 'newest': return t('email_list.sort.newest', 'Newest first');
case 'oldest': return t('email_list.sort.oldest', 'Oldest first');
case 'unread': return t('email_list.sort.unread', 'Unread first');
case 'starred': return t('email_list.sort.starred', 'Starred first');
case 'sender': return t('email_list.sort.sender', 'Sender (A–Z)');
case 'subject': return t('email_list.sort.subject', 'Subject (A–Z)');
case 'largest': return t('email_list.sort.largest', 'Largest first');
}
}
// Height of the sender line when the avatar is hidden (extra-compact density),
// used to anchor the unread dot on that first line.
const UNREAD_DOT_TEXT_LINE = 20;
@@ -176,6 +201,11 @@ const EmailRow = React.memo(function EmailRow({
const handlePress = React.useCallback(() => onPress(key), [onPress, key]);
const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]);
const verificationCode = useListVerificationCode(item);
// The row's parts, loaded once it is on screen: the chips show them and
// the paperclip goes when they are only inline images.
const attachments = useListAttachments(item, onOpenAttachment ? loadAttachments : undefined);
// Sent and Drafts rows show the recipient: the check says nothing about them.
const senderWarning = useSenderWarning(showRecipient ? undefined : item);
return (
<Pressable
@@ -210,12 +240,27 @@ const EmailRow = React.memo(function EmailRow({
</View>
)}
{density.showAvatar && (
<SenderAvatar
name={senderName}
email={senderEmail}
size={componentSizes.avatarMd}
disableImages={disableAvatarImages}
/>
// A tap on the avatar picks the row and starts selection mode, like
// a long press (#91).
<Pressable
onPress={handleLongPress}
hitSlop={4}
accessibilityRole="checkbox"
accessibilityState={{ checked: selected }}
accessibilityLabel={tr('email_list.batch_actions.select_row', 'Select {sender}: {subject}', {
sender: senderName,
subject: singleLine(item.subject) || tr('email_viewer.no_subject', '(No Subject)'),
})}
testID="email-row-avatar"
>
<SenderAvatar
name={senderName}
email={senderEmail}
size={componentSizes.avatarMd}
disableImages={disableAvatarImages}
senderWarning={senderWarning}
/>
</Pressable>
)}
{/* Content */}
@@ -238,7 +283,7 @@ const EmailRow = React.memo(function EmailRow({
{forwarded && (
<Forward size={componentSizes.statusIcon} color={c.textMuted} />
)}
{item.hasAttachment && (
{showsPaperclip(item.hasAttachment, attachments) && (
<Paperclip size={componentSizes.statusIcon} color={c.textMuted} />
)}
</View>
@@ -271,14 +316,15 @@ const EmailRow = React.memo(function EmailRow({
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
{showPreview && density.showPreview && (
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
{singleLine(item.preview)}
{singleLine(cleanPreview(item.preview))}
</Text>
)}
{verificationCode && <VerificationCodeChip code={verificationCode} disabled={selectionMode} inList />}
{onOpenAttachment && (
<ListAttachmentChips
email={item}
load={loadAttachments}
attachments={attachments}
pending={!!loadAttachments && item.hasAttachment && !item.attachments}
onOpen={onOpenAttachment}
disabled={selectionMode}
/>
@@ -419,6 +465,13 @@ export default function EmailListScreen({
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const storeSearchQuery = useEmailStore((s) => s.searchQuery);
const filters = useEmailStore((s) => s.filters);
// Another folder, tag or search starts at the top of its list, not at the
// offset the last one was scrolled to (webmail #1138).
const listRef = React.useRef<FlatList<Email>>(null);
const viewKey = listViewKey(currentMailboxId, storeSearchQuery, filters);
React.useEffect(() => {
listRef.current?.scrollToOffset({ offset: 0, animated: false });
}, [viewKey]);
const accountErrors = useEmailStore((s) => s.accountErrors);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const ensureMailboxes = useEmailStore((s) => s.ensureMailboxes);
@@ -440,7 +493,7 @@ export default function EmailListScreen({
const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox);
const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch);
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
const setSortAscending = useEmailStore((s) => s.setSortAscending);
const invalidateListOrder = useEmailStore((s) => s.invalidateListOrder);
const markSpam = useEmailStore((s) => s.markSpam);
const unmarkSpam = useEmailStore((s) => s.unmarkSpam);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
@@ -475,6 +528,15 @@ export default function EmailListScreen({
[mailboxes, currentMailboxId],
);
const currentRole = currentMailbox?.role ?? null;
// The Sort menu of the header (webmail e3f1ccce7): the order presets of
// Settings > Layout, one tap away.
const [sortMenuOpen, setSortMenuOpen] = React.useState(false);
const messageListOrder = useSettingsStore((s) => s.messageListOrder);
const messageListOrderScope = useSettingsStore((s) => s.messageListOrderScope);
const currentSortChoice = sortChoiceFor(
orderForMailbox(sanitizeSortLevels(messageListOrder), messageListOrderScope, currentRole),
sortAscending,
);
// The JMAP account behind the open folder (undefined = the user's own).
const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
@@ -721,7 +783,11 @@ export default function EmailListScreen({
deleteAction,
permanentlyDeleteJunk,
});
if (permanent && !(await confirmPermanentDelete(ids.length, t))) return;
if (permanent) {
if (!(await confirmPermanentDelete(ids.length, t))) return;
} else if (!(await confirmMailRemoval('delete', ids.length, t))) {
return;
}
if (ids.length === 1) await deleteEmailAction(ids[0], trashMailboxId, currentMailboxId);
else await deleteEmailsBatch(ids, trashMailboxId, currentMailboxId);
}, [currentMailboxId, trashMailboxId, inJunk, deleteAction, permanentlyDeleteJunk, deleteEmailAction, deleteEmailsBatch, t]);
@@ -734,8 +800,10 @@ export default function EmailListScreen({
case 'archive':
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
const ids = idsForRow(id);
if (ids.length > 1) reportFailure(archiveEmailsBatch(ids));
else reportFailure(archiveEmailAction(id));
reportFailure(confirmMailRemoval('archive', ids.length, t).then((ok) => {
if (!ok) return undefined;
return ids.length > 1 ? archiveEmailsBatch(ids) : archiveEmailAction(id);
}));
} else if (!archiveMailboxId) {
Alert.alert(
t('email_list.error', 'Error'),
@@ -857,6 +925,14 @@ export default function EmailListScreen({
() => expandThreadSelection(selectedIds, emails, disableThreading),
[selectedIds, emails, disableThreading],
);
// The open folder can't be picked in the move sheets only when the rows
// being moved are all in it: search hits from other folders can move into
// it (#1452).
const pendingMoveCurrentId = React.useMemo(() => {
if (!pendingMoveId) return currentMailboxId;
const wanted = new Set(expandThreadSelection([pendingMoveId], emails, disableThreading));
return moveSheetCurrentId(mailboxes, currentMailboxId, emails.filter((e) => wanted.has(rowKeyOf(e))));
}, [pendingMoveId, emails, disableThreading, mailboxes, currentMailboxId]);
const selectedEmails = React.useMemo(() => {
const wanted = new Set(selectedMessageIds);
return emails.filter((e) => wanted.has(rowKeyOf(e)));
@@ -900,12 +976,17 @@ export default function EmailListScreen({
deleteAction,
permanentlyDeleteJunk,
});
if (permanent && !(await confirmPermanentDelete(ids.length, t))) return;
if (permanent) {
if (!(await confirmPermanentDelete(ids.length, t))) return;
} else if (!(await confirmMailRemoval('delete', ids.length, t))) {
return;
}
await deleteEmailsBatch(ids, trash.id, currentMailboxId);
clearSelection();
};
const handleBulkArchive = async () => {
if (!(await confirmMailRemoval('archive', selectedMessageIds.length, t))) return;
await archiveEmailsBatch(selectedMessageIds);
clearSelection();
};
@@ -1127,6 +1208,18 @@ export default function EmailListScreen({
}
}, [ensureMailboxes, mailboxes.length]);
// A `mail/folder/<ref>` link opens its folder once the folders are here;
// one that names no folder of this account leaves the list as it is.
const pendingFolder = usePendingMailFolder((s) => s.ref);
React.useEffect(() => {
if (pendingFolder === null || mailboxes.length === 0) return;
const ref = usePendingMailFolder.getState().consume();
const id = ref === null ? null : resolveFolderRef(ref, mailboxes);
if (!id) return;
clearSearchAndFilters();
if (id !== currentMailboxId) void selectMailbox(id);
}, [pendingFolder, mailboxes, currentMailboxId, selectMailbox, clearSearchAndFilters]);
React.useEffect(() => {
if (mailboxes.length > 0 && !currentMailboxId) {
const own = ownMailboxes(mailboxes);
@@ -1190,17 +1283,17 @@ export default function EmailListScreen({
</View>
<Pressable
style={styles.filterButton}
onPress={() => setSortAscending(!sortAscending)}
onPress={() => setSortMenuOpen(true)}
accessibilityRole="button"
accessibilityLabel={sortAscending
? t('settings.appearance.message_list_order.direction.oldest_first', 'Oldest first')
: t('settings.appearance.message_list_order.direction.newest_first', 'Newest first')}
accessibilityHint={t('email_list.sort_toggle_hint', 'Reverses the mail sort order')}
accessibilityLabel={t('email_list.sort.title', 'Sort')}
accessibilityValue={currentSortChoice ? { text: sortChoiceLabel(currentSortChoice, t) } : undefined}
>
{sortAscending ? (
{currentSortChoice === 'newest' ? (
<ArrowDownWideNarrow size={18} color={c.textMuted} />
) : currentSortChoice === 'oldest' ? (
<ArrowUpNarrowWide size={18} color={c.primary} />
) : (
<ArrowDownWideNarrow size={18} color={c.textMuted} />
<ArrowUpDown size={18} color={c.primary} />
)}
</Pressable>
<Pressable
@@ -1621,6 +1714,7 @@ export default function EmailListScreen({
</View>
) : (
<FlatList
ref={listRef}
data={visibleEmails}
keyExtractor={emailKeyExtractor}
renderItem={renderEmailRow}
@@ -1764,17 +1858,18 @@ export default function EmailListScreen({
active={folderScope === 'current'}
onPress={() => setFolderScope('current')}
/>
{scopedMailboxes
.filter((m) => m.id !== currentMailboxId)
.slice(0, 12)
.map((m) => (
</View>
{/* Every folder, as a tree (it was a flat list cut at 12). */}
<View style={styles.scopeFolderList}>
{searchScopeFolders(scopedMailboxes, currentMailboxId).map((m) => (
<View key={m.id} style={{ marginLeft: m.depth * spacing.lg }}>
<ScopeChip
key={m.id}
label={localizeMailboxName(m.role, m.name, t)}
active={folderScope === m.id}
onPress={() => setFolderScope(m.id)}
/>
))}
</View>
))}
</View>
</View>
@@ -1905,11 +2000,11 @@ export default function EmailListScreen({
visible={pendingMoveId !== null}
onClose={() => setPendingMoveId(null)}
mailboxes={mailboxes}
currentMailboxId={currentMailboxId}
currentMailboxId={pendingMoveCurrentId}
onPick={(toId) => {
const id = pendingMoveId;
setPendingMoveId(null);
if (id && currentMailboxId && toId !== currentMailboxId) {
if (id && currentMailboxId && toId !== pendingMoveCurrentId) {
const ids = idsForRow(id);
if (ids.length > 1) reportFailure(moveEmailsToMailbox(ids, toId));
else reportFailure(moveToMailboxAction(id, currentMailboxId, toId));
@@ -1917,11 +2012,40 @@ export default function EmailListScreen({
}}
/>
<ActionSheet
visible={sortMenuOpen}
title={t('email_list.sort.title', 'Sort')}
onClose={() => setSortMenuOpen(false)}
items={[
...SORT_CHOICES.map((choice) => ({
key: choice.key,
label: sortChoiceLabel(choice.key, t),
trailing: currentSortChoice === choice.key ? <Check size={16} color={c.primary} /> : undefined,
onPress: () => {
setSortMenuOpen(false);
const changes = sortChoiceChanges(choice.key, messageListOrderScope, currentRole);
if (!changes) return;
const { updateSetting } = useSettingsStore.getState();
updateSetting('messageListOrder', changes.messageListOrder);
updateSetting('mailSortAscending', changes.mailSortAscending);
if (changes.messageListOrderScope) updateSetting('messageListOrderScope', changes.messageListOrderScope);
invalidateListOrder();
},
})),
...(currentSortChoice === null ? [{
key: 'custom',
label: t('email_list.sort.custom', 'Custom order (in Settings)'),
trailing: <Check size={16} color={c.primary} />,
onPress: () => setSortMenuOpen(false),
}] : []),
]}
/>
<MoveSheet
visible={batchMoveOpen}
onClose={() => setBatchMoveOpen(false)}
mailboxes={mailboxes}
currentMailboxId={currentMailboxId}
currentMailboxId={moveSheetCurrentId(mailboxes, currentMailboxId, selectedEmails)}
onPick={handleBatchMovePick}
/>
@@ -2308,6 +2432,7 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm,
marginTop: 4,
},
scopeFolderList: { alignItems: 'flex-start', gap: spacing.xs, marginTop: spacing.sm },
triToggle: {
flexDirection: 'row',
alignItems: 'center',
+84 -23
View File
@@ -11,8 +11,11 @@ import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
ChevronLeft, ChevronRight, Archive, Mail, MailOpen,
FolderInput, ShieldAlert, ShieldCheck, X, Check,
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter,
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter, FileText, Type,
} from '../components/icons';
import { pickEmailBody } from '../lib/email-body';
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
import { NewTagRow } from '../components/NewTagRow';
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
import { isSieveSupported } from '../api/sieve';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
@@ -47,7 +50,8 @@ import { replyComposeParams } from '../lib/reply-compose';
import { singleLine } from '../lib/single-line';
import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment';
import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer-pages';
import { accountScopedId } from '../lib/thread-utils';
import { accountScopedId, isReplyableMember, quickReplyTarget } from '../lib/thread-utils';
import { scheduledEmailIdsInThread } from '../api/thread-scheduled';
import type { Email, EmailAddress, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
@@ -177,6 +181,13 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
// Per-message override of the light/dark rendering (More sheet toggle).
const [themeOverrides, setThemeOverrides] = React.useState<Record<string, 'light' | 'dark'>>({});
// Per-message "Show as plain text" (More sheet); unset follows "Plain Text Only".
const [plainOverrides, setPlainOverrides] = React.useState<Record<string, boolean>>({});
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
const showFormatted = React.useCallback(
(m: Email) => setPlainOverrides((prev) => ({ ...prev, [m.id]: false })),
[],
);
// Message details and conversations come from the shared cache
// (lib/email-detail-cache): every rendered pane, the active one and its
@@ -537,14 +548,18 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
);
return;
}
performDelete();
// "Confirm before deleting" (webmail lib/mail-removal-confirm.ts).
void confirmMailRemoval('delete', 1, t).then((ok) => { if (ok) performDelete(); });
};
const onArchive = () => {
if (!email || !canArchive) return;
archiveEmailAction(email.id, { email, accountId: ownerAccountId })
.catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err));
navigation.goBack();
void confirmMailRemoval('archive', 1, t).then((ok) => {
if (!ok) return;
archiveEmailAction(email.id, { email, accountId: ownerAccountId })
.catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err));
navigation.goBack();
});
};
// The same store path as the list and swipe actions: files into the
@@ -679,7 +694,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
label: t('email_viewer.tag', 'Tag'),
icon: (s, col) => <Tag size={s} color={col} />,
onPress: () => setTagMenuOpen(true),
available: keywordDefs.length > 0,
// Always: the tag sheet can make a new tag.
available: true,
},
};
@@ -693,6 +709,10 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
const showMarkUnread = windowWidth >= 340;
const showArchive = windowWidth >= 400 && canArchive;
// The active message read as plain text, and whether it has HTML to switch from.
const activePlainView = email ? plainOverrides[email.id] ?? plainTextMode : false;
const activeHasHtml = !!email && !!pickEmailBody(email).html;
// Current rendering mode of the active message, for the More sheet label.
const activeRenderDark = email
? (themeOverrides[email.id] ? themeOverrides[email.id] === 'dark' : !emailAlwaysLightMode && resolvedTheme === 'dark')
@@ -817,6 +837,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
currentMailboxRole={currentMailboxRole}
identities={identities}
themeOverrides={themeOverrides}
plainOverrides={plainOverrides}
onShowFormatted={showFormatted}
ensureDetail={ensureDetail}
ensureDetails={ensureDetails}
ensureThread={ensureThread}
@@ -878,8 +900,14 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
isInJunk={isInJunk}
canViewSource={!!email?.blobId}
canExport={!!email?.blobId}
canTag={keywordDefs.length > 0}
canTag
renderDark={activeRenderDark}
plainView={activeHasHtml ? activePlainView : null}
onTogglePlainView={() => {
setMoreMenuOpen(false);
if (!email) return;
setPlainOverrides((prev) => ({ ...prev, [email.id]: !activePlainView }));
}}
hasSender={!!email?.from?.[0]?.email}
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
@@ -971,6 +999,8 @@ interface EmailPaneProps {
currentMailboxRole: string | null;
identities: Identity[];
themeOverrides: Record<string, 'light' | 'dark'>;
plainOverrides: Record<string, boolean>;
onShowFormatted: (email: Email) => void;
ensureDetail: (id: string) => Promise<Email | null>;
ensureDetails: (ids: string[]) => void;
ensureThread: (threadId: string) => void;
@@ -986,6 +1016,8 @@ interface EmailPaneProps {
onZoomChange: (zoom: { pinching: boolean; zoomed: boolean }) => void;
}
const NO_IDS: ReadonlySet<string> = new Set();
// One swipeable page: the subject plus either a single message or the whole
// conversation as collapsible cards (newest + unread expanded, mark-read on
// expand). The pager keeps three of these mounted (prev, current, next) so a
@@ -993,12 +1025,15 @@ interface EmailPaneProps {
// members' headers; bodies are only downloaded for the cards that are open.
function EmailPane({
id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint,
threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails,
threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, plainOverrides, onShowFormatted,
ensureDetail, ensureDetails,
ensureThread, scheduleMarkRead, styles, bottomBarHeight, kbObstruction, onToggleStar, onAddressPress,
onEmailPatched, onReply, onSwipe, onZoomChange,
}: EmailPaneProps) {
const c = useColors();
const t = useLocaleStore((s) => s.t);
// The quick reply box can be turned off (webmail #1370).
const quickReplyEnabled = useSettingsStore((s) => s.quickReplyEnabled);
// Freeze the pane's vertical scroll while a pinch is in flight so a two-
// finger zoom can't fling the page.
const [pinching, setPinching] = React.useState(false);
@@ -1042,6 +1077,19 @@ function EmailPane({
React.useEffect(() => () => { readTimers.forEach((cancel) => cancel()); readTimers.clear(); }, [readTimers]);
// Members waiting for a scheduled send: no Reply on them, and the quick
// reply answers the newest message that is neither one nor a draft (#1404).
const [scheduledIds, setScheduledIds] = React.useState<ReadonlySet<string>>(NO_IDS);
const isConversation = !!threadIds && threadIds.length > 1;
React.useEffect(() => {
if (!active || !threading || !threadId || !isConversation) return undefined;
let cancelled = false;
void scheduledEmailIdsInThread(threadId, jmapAccountId).then((ids) => {
if (!cancelled) setScheduledIds(ids.size > 0 ? ids : NO_IDS);
});
return () => { cancelled = true; };
}, [active, threading, threadId, isConversation, jmapAccountId]);
// Keeps the quick reply in view while the keyboard opens. Above the
// skeleton's early return: a page opened by id alone (a notification)
// renders the skeleton first, and a hook after it crashed the app.
@@ -1121,7 +1169,7 @@ function EmailPane({
// and WebView stay mounted when a conversation arrives around it.
const members = conversation ?? [shown];
const firstOpenId = conversation?.find((m) => expanded?.has(m.id) ?? m.id === id)?.id;
const newest = conversation ? conversation[conversation.length - 1] : email;
const newest = quickReplyTarget(conversation ?? (email ? [email] : []), scheduledIds);
// The quick reply quotes the message, so it waits for the newest one's body.
const newestLoaded = !!newest && !!peekDetail(newest.id, jmapAccountId);
const onPaneZoom = (z: { pinching: boolean; zoomed: boolean }) => {
@@ -1209,7 +1257,7 @@ function EmailPane({
fill={!asConversation}
email={full ?? m}
expanded={open}
replyable={!!full}
replyable={!!full && isReplyableMember(m, scheduledIds)}
deferBody={!full || !bodyEnabled}
onBodySettled={onBodySettled}
onToggleExpanded={() => toggleCard(m.id)}
@@ -1219,6 +1267,8 @@ function EmailPane({
currentMailboxRole={currentMailboxRole}
active={active}
themeOverride={themeOverrides[m.id] ?? null}
plainView={plainOverrides[m.id] ?? null}
onShowFormatted={onShowFormatted}
onSwipe={onSwipe}
onZoomChange={onPaneZoom}
onToggleStar={onToggleStar}
@@ -1238,7 +1288,7 @@ function EmailPane({
</View>
<View style={styles.quickReplySlot}>
{newest && newestLoaded && (
{quickReplyEnabled && newest && newestLoaded && (
<QuickReplyBox
email={newest}
jmapAccountId={jmapAccountId}
@@ -1307,6 +1357,9 @@ interface MoreMenuSheetProps {
canViewSource: boolean;
canExport: boolean;
renderDark: boolean;
/** The active message read as plain text; null when it has no HTML to switch from. */
plainView: boolean | null;
onTogglePlainView: () => void;
hasSender: boolean;
onArchive: () => void;
onToggleUnread: () => void;
@@ -1324,7 +1377,7 @@ interface MoreMenuSheetProps {
function MoreMenuSheet({
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
showSpam, isInJunk, canViewSource, canExport, renderDark, hasSender,
showSpam, isInJunk, canViewSource, canExport, renderDark, plainView, onTogglePlainView, hasSender,
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions,
onForwardAsAttachment, onRules, onViewSource, onExport,
}: MoreMenuSheetProps) {
@@ -1430,6 +1483,13 @@ function MoreMenuSheet({
onPress={onToggleSpam}
/>
)}
{plainView !== null && (
<MoreMenuItem
icon={plainView ? <Type size={18} color={c.textSecondary} /> : <FileText size={18} color={c.textSecondary} />}
label={plainView ? t('email_viewer.show_formatted', 'Show formatted') : t('email_viewer.show_as_plain_text', 'Show as plain text')}
onPress={onTogglePlainView}
/>
)}
<MoreMenuItem
icon={renderDark ? <Sun size={18} color={c.textSecondary} /> : <Moon size={18} color={c.textSecondary} />}
label={renderDark ? t('email_viewer.view_light_mode', 'View in light mode') : t('email_viewer.view_dark_mode', 'View in dark mode')}
@@ -1539,14 +1599,14 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
</Pressable>
</View>
</View>
{keywords.length === 0 ? (
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
{t('email_viewer.tag_no_matches', 'No matching tags')}
</Text>
) : (
// Scrolls like the More sheet: a long tag list (or a landscape
// window) ran past the screen with no way to reach the last tags.
<ScrollView style={styles.sheetScroll} bounces={false}>
{/* Scrolls like the More sheet: a long tag list (or a landscape
window) ran past the screen with no way to reach the last tags. */}
<ScrollView style={styles.sheetScroll} bounces={false} keyboardShouldPersistTaps="handled">
{keywords.length === 0 && (
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
{t('email_viewer.tag_no_matches', 'No matching tags')}
</Text>
)}
{keywords.map((kw) => {
const token = keywordToken(kw.id);
const active = !!activeKeywords[token];
@@ -1561,8 +1621,9 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
/>
);
})}
</ScrollView>
)}
{/* "New tag" (webmail 19676a14f): made, saved and put on the message. */}
<NewTagRow keywords={keywords} onCreated={onToggle} />
</ScrollView>
</Animated.View>
</Modal>
);
+21 -7
View File
@@ -23,9 +23,12 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { singleLine } from '../lib/single-line';
import { cleanPreview } from '../lib/preview-text';
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
import { onStateChangeType } from '../lib/state-change-bus';
import { describeSenderVerification, senderWarningOf } from '../lib/email-headers';
import { createUnifiedReloadTracker } from '../lib/unified-reload';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -243,9 +246,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
patchRows(targets, { $flagged: starred ? true : null });
patchUnifiedKeywords(targets, { $flagged: starred ? true : null }).catch(fail);
};
const archive = (targets: UnifiedEmail[]) => {
// Resolve whether the messages went: a confirm can say no.
const archive = async (targets: UnifiedEmail[]): Promise<boolean> => {
if (!(await confirmMailRemoval('archive', targets.length, t))) return false;
removeRows(targets);
moveUnifiedEmails(targets, 'archive').catch(fail);
return true;
};
const spam = (targets: UnifiedEmail[]) => {
removeRows(targets);
@@ -253,24 +259,29 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
markRead: role !== 'junk' && deleteAction === 'trash-and-read',
}).catch(fail);
};
const remove = async (targets: UnifiedEmail[]) => {
const remove = async (targets: UnifiedEmail[]): Promise<boolean> => {
const permanent = targets.some((e) => isPermanentDelete({
inTrash: role === 'trash' || isInUnifiedTrash(e),
inJunk: role === 'junk',
deleteAction,
permanentlyDeleteJunk,
}));
if (permanent && !(await confirmPermanentDelete(targets.length, t))) return;
if (permanent) {
if (!(await confirmPermanentDelete(targets.length, t))) return false;
} else if (!(await confirmMailRemoval('delete', targets.length, t))) {
return false;
}
removeRows(targets);
deleteUnifiedEmails(targets, {
permanent: deleteAction === 'permanent' || (permanentlyDeleteJunk && role === 'junk'),
markRead: deleteAction === 'trash-and-read',
}).catch(fail);
return true;
};
const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => {
switch (action) {
case 'archive': if (role !== 'archive') archive([email]); break;
case 'archive': if (role !== 'archive') void archive([email]); break;
case 'delete': void remove([email]); break;
case 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); break;
case 'read': setRead([email], !email.keywords?.$seen); break;
@@ -313,6 +324,8 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
const unread = !item.keywords?.$seen;
const starred = !!item.keywords?.$flagged;
const isSelected = selected.has(rowKey(item));
// Sent and Drafts rows show the recipient: the check says nothing about them.
const verification = showRecipient ? null : senderWarningOf(item);
return (
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
@@ -344,6 +357,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email}
size={componentSizes.avatarMd}
disableImages={role === 'junk' && !showAvatarsInJunk}
senderWarning={verification ? describeSenderVerification(verification, t).label : undefined}
/>
{acc && accountIds.length > 1 && (
<View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />
@@ -383,7 +397,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
</View>
{/* Same settings as the folder list: preview on/off, density, text size. */}
{item.preview && showPreview && density.showPreview ? (
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(item.preview)}</Text>
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(cleanPreview(item.preview))}</Text>
) : null}
</View>
</Pressable>
@@ -440,7 +454,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
)}
{role !== 'archive' && (
<Pressable
onPress={() => { archive(selectedEmails); clearSelection(); }}
onPress={() => { void archive(selectedEmails).then((done) => { if (done) clearSelection(); }); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
@@ -450,7 +464,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
</Pressable>
)}
<Pressable
onPress={() => { void remove(selectedEmails).then(clearSelection); }}
onPress={() => { void remove(selectedEmails).then((done) => { if (done) clearSelection(); }); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import type { Email } from '../../api/types';
import type { Email, Mailbox } from '../../api/types';
import {
boundEmailCache,
PERSISTED_ROWS_PER_FOLDER,
@@ -102,6 +102,8 @@ describe('boundEmailCache', () => {
});
});
const INBOX = { id: 'inbox', name: 'Inbox', role: 'inbox', isShared: false } as unknown as Mailbox;
describe('email-store persistence', () => {
const { partialize, merge } = useEmailStore.persist.getOptions();
@@ -149,6 +151,7 @@ describe('email-store persistence', () => {
it('restores the open folder into the list on hydration', () => {
const live = rows('live', 3);
const persisted = JSON.parse(JSON.stringify(partialize!(viewState({
mailboxes: [INBOX],
emails: live,
totalEmails: 42,
queryState: 'q2',
@@ -170,7 +173,7 @@ describe('email-store persistence', () => {
const legacy = {
accountSnapshots: {},
activeAccountId: 'me',
mailboxes: [],
mailboxes: [INBOX],
emailStates: {},
currentMailboxId: 'inbox',
mailboxSnapshots: { inbox: snap },
@@ -190,4 +193,24 @@ describe('email-store persistence', () => {
const initial = useEmailStore.getInitialState();
expect(merge!(undefined, initial)).toBe(initial);
});
// A cold start reopened the folder (or the search) the app was left in;
// the webmail opens the Inbox (64b39c504).
it('opens the Inbox on a cold start, with its cached rows', () => {
const work = { id: 'work', name: 'Work', role: null, isShared: false } as unknown as Mailbox;
const persisted = JSON.parse(JSON.stringify(partialize!(viewState({
mailboxes: [INBOX, work],
currentMailboxId: 'work',
emails: rows('work', 2),
totalEmails: 2,
mailboxSnapshots: { inbox: { emails: rows('in', 3), total: 3, queryState: 'qi' } },
}))));
const restored = merge!(persisted, useEmailStore.getInitialState());
expect(restored.currentMailboxId).toBe('inbox');
expect(restored.emails.map((e) => e.id)).toEqual(['in0', 'in1', 'in2']);
// The folder's own rows are still cached for when it is opened.
expect(restored.mailboxSnapshots.work.emails).toHaveLength(2);
});
});
@@ -626,6 +626,8 @@ describe('tag view across the own and the team account (#1038)', () => {
mail('o1', 'inbox', 20, 'own red 1', { keywords: { [RED]: true } }),
mail('o2', 'archive', 10, 'own red 2', { keywords: { [RED]: true, $seen: true } }),
mail('o3', 'inbox', 21, 'own plain'),
// Deleted, but it keeps its keywords: not listed under the tag (#1156).
mail('o4', 'trash', 24, 'own red deleted', { keywords: { [RED]: true } }),
],
team: [
mail('m1', 't-inbox', 22, 'team red 1', { keywords: { [RED]: true } }),
@@ -647,9 +649,10 @@ describe('tag view across the own and the team account (#1038)', () => {
expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']);
expect(server.current!.requests).toHaveLength(1);
const queries = server.current!.callsOf('Email/query');
// Each account leaves its own Trash and Junk out (webmail #1156).
expect(queries.map(([, a]) => [a.accountId, a.filter])).toEqual([
['c', { hasKeyword: RED }],
['team', { hasKeyword: RED }],
['c', { operator: 'AND', conditions: [{ hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
['team', { operator: 'AND', conditions: [{ hasKeyword: RED }, { inMailboxOtherThan: ['t-trash', 't-junk'] }] }],
]);
});
@@ -669,8 +672,8 @@ describe('tag view across the own and the team account (#1038)', () => {
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
expect(last.map(([, a]) => a.filter)).toEqual([
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }] },
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }] },
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }] },
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }, { inMailboxOtherThan: ['t-trash', 't-junk'] }] },
]);
});
@@ -689,7 +692,7 @@ describe('tag view across the own and the team account (#1038)', () => {
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
expect(last[0][1].filter).toEqual({
operator: 'AND',
conditions: [{ text: '"red 2"' }, { text: 'notes' }, { hasKeyword: RED }],
conditions: [{ text: '"red 2"' }, { text: 'notes' }, { hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }],
});
});
await vi.waitFor(() => expect(useEmailStore.getState().loading).toBe(false));
+38
View File
@@ -1566,6 +1566,44 @@ describe('email-store', () => {
useSettingsStore.getState().updateSetting('deleteAction', 'trash');
});
// Read, star and tag changes from the list had no Undo (webmail
// ee17c0dba, lib/email-action-toast.ts): now each message gets back the
// keyword it had, and nothing else moves.
it('offers an undo for a read, star or tag change that puts back only that keyword', async () => {
const mockRestore = emailApi.restoreEmailMailboxes as ReturnType<typeof vi.fn>;
const mockPatchPerEmail = emailApi.patchKeywordsPerEmail as ReturnType<typeof vi.fn>;
useEmailStore.setState({
mailboxes: [inbox, junk],
currentMailboxId: 'mb-1',
emails: [
{ id: 'e1', keywords: {}, mailboxIds: { 'mb-1': true } } as any,
{ id: 'e2', keywords: { $seen: true }, mailboxIds: { 'mb-1': true } } as any,
],
});
await useEmailStore.getState().setKeywordForEmails(['e1', 'e2'], '$seen', true);
const undo = useEmailStore.getState().pendingUndo!;
expect(undo.kind).toBe('keywords');
expect(undo.label).toContain('marked as read');
await useEmailStore.getState().undoLast();
expect(mockRestore).not.toHaveBeenCalled();
expect(mockPatchPerEmail).toHaveBeenCalledWith(
[{ id: 'e1', patch: { $seen: null } }, { id: 'e2', patch: { $seen: true } }],
undefined,
);
const byId = Object.fromEntries(useEmailStore.getState().emails.map((e) => [e.id, e.keywords]));
expect(byId.e1.$seen).toBeUndefined();
expect(byId.e2.$seen).toBe(true);
// Tags and stars too; the reader's own toggles get no bar.
await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:work', true);
expect(useEmailStore.getState().pendingUndo?.kind).toBe('keywords');
useEmailStore.getState().clearUndo();
await useEmailStore.getState().setKeywordForEmails(['e1'], '$flagged', true, { email: useEmailStore.getState().emails[0] });
expect(useEmailStore.getState().pendingUndo).toBeNull();
});
it('unmarkSpam moves back to Inbox with $notjunk', async () => {
const mockUndoSpam = emailApi.undoSpam as ReturnType<typeof vi.fn>;
useEmailStore.setState({
@@ -0,0 +1,33 @@
import { describe, it, expect, beforeEach } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSettingsStore } from '../settings-store';
// Defaults the reader pass changed. Kept apart from settings-store.test.ts so
// the agents working on settings in parallel don't edit the same test file.
describe('settings defaults (reader pass)', () => {
beforeEach(async () => {
await AsyncStorage.clear();
useSettingsStore.getState().resetToDefaults();
});
it('keeps sender favicons off until the user turns them on', () => {
// The app fetches them from icons.duckduckgo.com itself, which tells a
// third party every sender domain and the device's IP. The webmail
// proxies them through its own server, so its default doesn't carry over.
expect(useSettingsStore.getState().senderFavicons).toBe(false);
});
// Quick reply could not be turned off (webmail #1370). On by default like
// the webmail, under the webmail's key so export and import carry it.
it('keeps the quick reply on by default and lets it be turned off', async () => {
expect(useSettingsStore.getState().quickReplyEnabled).toBe(true);
const { mergeWithDefaults, toExportShape } = await import('../settings-store');
expect(mergeWithDefaults({ quickReplyEnabled: false }).quickReplyEnabled).toBe(false);
expect(toExportShape(useSettingsStore.getState())).toHaveProperty('quickReplyEnabled', true);
const fs = await import('node:fs');
const path = await import('node:path');
const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailThreadScreen.tsx'), 'utf8');
expect(screen).toMatch(/\{quickReplyEnabled && newest && newestLoaded && \(/);
});
});
+98 -4
View File
@@ -303,9 +303,12 @@ export function searchSkipsTrashAndJunk(
filters: EmailFilters,
openRole?: string | null,
): boolean {
if (filters.keyword) return false;
if (filters.folder === SCOPE_EXCEPT_TRASH_JUNK) return true;
if (filters.folder) return false;
// A tag view leaves Trash and Junk out, as Gmail's label views do: a
// deleted message keeps its keywords and stayed listed under the tag
// (webmail #1156). It is still in the Trash folder itself.
if (filters.keyword) return true;
return effectiveFolderScope(searchQuery, filters, openRole) === 'all';
}
@@ -315,11 +318,34 @@ function openRoleOf(state: Partial<Pick<EmailState, 'mailboxes' | 'currentMailbo
return state.mailboxes.find((m) => m.id === state.currentMailboxId)?.role ?? undefined;
}
/**
* The undo bar's label after a read, star or tag change from the list (webmail
* lib/email-action-toast.ts, ee17c0dba). Null for other keywords, which get
* no Undo.
*/
export function keywordUndoLabel(token: string, on: boolean, count: number): string | null {
if (token === '$seen') {
return on
? t('notifications.emails_marked_read', '{count, plural, one {# email marked as read} other {# emails marked as read}}', { count })
: t('notifications.emails_marked_unread', '{count, plural, one {# email marked as unread} other {# emails marked as unread}}', { count });
}
if (token === '$flagged') {
return on ? t('notifications.email_starred', 'Email starred') : t('notifications.email_unstarred', 'Email unstarred');
}
if (token.startsWith('$label:')) {
return on
? t('notifications.email_tagged', 'Email tagged')
: t('notifications.tag_removed_count', '{count, plural, one {Tag removed from # email} other {Tag removed from # emails}}', { count });
}
return null;
}
// Snapshot of an action that can still be reversed via the undo snackbar.
// We store the full email object so undo can re-insert it into the visible list
// optimistically without waiting for a refetch.
export interface UndoEntry {
kind: 'archive' | 'delete' | 'move' | 'spam';
/** `keywords`: a read, star or tag change; undo puts back only `keywordPatch`'s keywords. */
kind: 'archive' | 'delete' | 'move' | 'spam' | 'keywords';
/** Human-readable label shown in the snackbar (e.g. "Email archived"). */
label: string;
/** Time the entry was created - the snackbar uses this to drive its timer. */
@@ -603,7 +629,7 @@ export function spansAccounts(
}
// The raw JMAP ids of one account's Trash and Junk (undefined = the user's own).
function trashAndJunkIds(mailboxes: Mailbox[], accountId: string | undefined): string[] {
export function trashAndJunkIds(mailboxes: Mailbox[], accountId: string | undefined): string[] {
return accountMailboxes(mailboxes, accountId)
.filter((m) => isTrashOrJunkRole(m.role))
.map((m) => m.originalId ?? m.id);
@@ -886,6 +912,16 @@ function snapshotFromActive(state: EmailState): AccountSnapshot {
};
}
/**
* A cold start opens the Inbox, not the folder the app was last left in
* (webmail 64b39c504); its cached rows still show at once. Snapshots of the
* other accounts keep their folder for an account switch.
*/
export function coldStartView(snap: AccountSnapshot): AccountSnapshot {
const inbox = snap.mailboxes?.find((m) => m.role === 'inbox' && !m.isShared);
return { ...snap, currentMailboxId: inbox?.id ?? null };
}
function viewFromSnapshot(snap: AccountSnapshot | null): {
mailboxes: Mailbox[];
mailboxState?: string;
@@ -2106,6 +2142,26 @@ export const useEmailStore = create<EmailState>()(
for (const { accountId, emails } of written) {
for (const e of emails) patchCache(e.id, { keywords: patch }, accountId);
}
// Undo for a read, star or tag change from the list: each message gets
// back the keyword it had (the reader's own toggles get no bar).
const changed = written.flatMap(({ accountId, emails }) => emails.map((email) => ({ email, accountId })));
const label = !viewed && changed.length > 0 ? keywordUndoLabel(token, on, changed.length) : null;
if (label) {
set({
pendingUndo: {
kind: 'keywords',
label,
createdAt: Date.now(),
keywordPatch: patch,
items: changed.map(({ email, accountId }) => ({
email,
originalMailboxIds: { ...email.mailboxIds },
originalKeywords: { ...email.keywords },
accountId,
})),
},
});
}
if (refusal) throw refusal;
},
@@ -2114,6 +2170,44 @@ export const useEmailStore = create<EmailState>()(
if (!entry) return;
set({ pendingUndo: null });
// A read, star or tag change: only those keywords go back; the messages
// stay in whatever folder they are in now.
if (entry.kind === 'keywords') {
const keywordPatch = entry.keywordPatch ?? {};
const undo = entry.items.map((it) => ({
id: it.email.id,
key: rowKeyOf(it.email),
accountId: it.accountId ?? entry.accountId,
patch: revertKeywordPatch(keywordPatch, it.originalKeywords),
}));
const accounts = new Set(undo.map((u) => u.accountId ?? ''));
try {
await applyOrQueueBatch(
undo.map((u): OutboxOp => ({ kind: 'keywords', emailId: u.id, accountId: u.accountId, patch: u.patch })),
async () => {
for (const account of accounts) {
await patchKeywordsPerEmail(
undo.filter((u) => (u.accountId ?? '') === account).map(({ id, patch }) => ({ id, patch })),
account || undefined,
);
}
},
);
} catch (err) {
set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') });
return;
}
const byKey = new Map(undo.map((u) => [u.key, u.patch]));
set({
emails: get().emails.map((e) => {
const patch = byKey.get(rowKeyOf(e));
return patch ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e;
}),
});
for (const u of undo) patchCache(u.id, { keywords: u.patch }, u.accountId);
return;
}
// An action on a list spanning accounts is undone in each item's account.
const accountOf = (it: UndoEntry['items'][number]) => it.accountId ?? entry.accountId;
const byAccount = new Map<string, { accountId?: string; items: UndoEntry['items'] }>();
@@ -2332,7 +2426,7 @@ export const useEmailStore = create<EmailState>()(
merge: (persisted, current) => {
if (!persisted) return current;
const cache = persisted as PersistedEmailCache;
return { ...current, ...cache, ...viewFromSnapshot(cache) };
return { ...current, ...cache, ...viewFromSnapshot(coldStartView(cache)) };
},
},
),
+6 -1
View File
@@ -322,6 +322,9 @@ export interface PersistedSettings {
toastDuration: number;
// Push only for mail that lands in the Inbox (webmail #983, same key).
pushNotifyInboxOnly: boolean;
// reader: the quick reply box under a conversation can be turned off
// (webmail #1370, same key as the webmail's for export/import).
quickReplyEnabled: boolean;
}
const DEFAULT_PERSISTED: PersistedSettings = {
@@ -332,7 +335,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
externalContentPolicy: 'ask',
trustedSenders: [],
trustedSendersAddressBook: null,
senderFavicons: true,
senderFavicons: false,
hideInlineImageAttachments: true,
groupContactsByLetter: true,
@@ -482,6 +485,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
// platform:
toastDuration: -1,
pushNotifyInboxOnly: false,
// reader:
quickReplyEnabled: true,
};
export interface SettingsState extends PersistedSettings {
+10 -4
View File
@@ -20,7 +20,13 @@ interface TagCountsState {
/** `generation` the counts were fetched at. */
fetchedAt: number;
/** Fetch the counts unless the cached ones are current. */
ensure: (login: string, tagIds: string[], accountIds: Array<string | undefined>) => Promise<void>;
ensure: (
login: string,
tagIds: string[],
accountIds: Array<string | undefined>,
/** Each account's Trash and Junk ids (key '' = the own account), left out of the counts. */
excluded?: Record<string, string[]>,
) => Promise<void>;
/** An Email change arrived: the next `ensure` fetches again. */
invalidate: () => void;
}
@@ -35,8 +41,8 @@ export const useTagCountsStore = create<TagCountsState>((set, get) => ({
generation: 0,
fetchedAt: -1,
ensure: (login, tagIds, accountIds) => {
const key = `${login}|${accountIds.map((id) => id ?? '').join(',')}|${tagIds.join(',')}`;
ensure: (login, tagIds, accountIds, excluded = {}) => {
const key = `${login}|${accountIds.map((id) => id ?? '').join(',')}|${tagIds.join(',')}|${JSON.stringify(excluded)}`;
if (get().login !== login) set({ counts: {}, login, key: null });
const { generation } = get();
if (get().key === key && get().fetchedAt === generation) return Promise.resolve();
@@ -44,7 +50,7 @@ export const useTagCountsStore = create<TagCountsState>((set, get) => ({
const seq = ++lastSeq;
const promise = (async () => {
try {
const list = await fetchTagCounts(tagIds, accountIds);
const list = await fetchTagCounts(tagIds, accountIds, excluded);
// A later ensure (another login, other tags) supersedes this one.
if (inflight?.seq !== seq || get().login !== login) return;
set({ counts: Object.fromEntries(list.map((c) => [c.id, c])), key, fetchedAt: generation });