mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 \}\)/);
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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 & chips €5<p>Next <a href="https://ex.com/?a=1&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();
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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\]\);/);
|
||||
});
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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)'\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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\)\}/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
`;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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\);/);
|
||||
});
|
||||
});
|
||||
@@ -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':
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 && \(/);
|
||||
});
|
||||
});
|
||||
@@ -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)) };
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user