Merge branch 'fx/read2' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 04:44:36 +01:00
19 changed files with 506 additions and 125 deletions
+21
View File
@@ -311,6 +311,27 @@ describe('email operations', () => {
mockRequest.mockReset();
});
it("sends each account's own filter in the shared request", async () => {
(jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>).mockReturnValueOnce(16);
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
methodResponses: calls.map(([name, , id]) => (name === 'Email/query'
? [name, { ids: [], total: 0 }, id]
: [name, { list: [] }, id])),
}));
const sort = [{ property: 'receivedAt', isAscending: false }];
const own = { operator: 'AND', conditions: [{ text: 'x' }, { inMailboxOtherThan: ['trash'] }] };
await queryEmailPagesAcrossAccounts(
[{ position: 0, sort, filter: own }, { accountId: 'g1', position: 0, sort }],
{ limit: 10, filter: { text: 'x' } },
);
expect(mockRequest).toHaveBeenCalledTimes(1);
const queries = mockRequest.mock.calls[0][0].filter((c: unknown[]) => c[0] === 'Email/query');
expect(queries.map((c: unknown[]) => (c[1] as { filter: unknown }).filter)).toEqual([own, { text: 'x' }]);
mockRequest.mockReset();
});
it('leaves Thread/get out unless asked, and targets a shared account', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
+6 -1
View File
@@ -543,6 +543,11 @@ export interface AccountPageTarget {
accountId?: string;
position: number;
sort: Array<{ property: string; isAscending: boolean; keyword?: string }>;
/**
* This account's own filter, in place of the shared `options.filter`
* (the default search leaves each account's own Trash and Junk out).
*/
filter?: Record<string, unknown>;
}
export type AccountPage =
@@ -573,7 +578,7 @@ export async function queryEmailPagesAcrossAccounts(
position: target.position,
limit,
sort: target.sort,
filter: options.filter,
filter: target.filter ?? options.filter,
accountId: target.accountId,
});
calls.push(['Email/query', args, `${index}:q`]);
+2 -8
View File
@@ -4,7 +4,7 @@ import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens';
import { perfEndAfterPaint } from '../lib/perf-marks';
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
import { buildEditorHtml, isEditorNavigationAllowed, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
export type RichTextCommand =
| 'bold'
@@ -276,13 +276,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
style={styles.webview}
containerStyle={styles.webviewContainer}
onShouldStartLoadWithRequest={(request) => {
// Allow only the initial about:blank doc + data URIs we emit
// ourselves. Reject `about:*` (e.g. about:srcdoc) and every other
// scheme so a paste-injected anchor can't navigate the editor.
const url = request.url;
return !url || url === 'about:blank' || url.startsWith('data:');
}}
onShouldStartLoadWithRequest={(request) => isEditorNavigationAllowed(request.url)}
/>
</View>
);
+12 -11
View File
@@ -15,10 +15,10 @@ import { SubmissionQuotaError } from '../../api/jmap-result';
import { sendFailureMessage } from '../../lib/send-failure-message';
import {
isValidUnsubscribeUrl, parseUnsubscribeMailto, isOneClickUnsubscribe, unsubscribeConfirmMessage,
unsubscribeSendFolders,
} from '../../lib/unsubscribe';
import type { ListHeaders } from '../../lib/email-headers';
import { findReceivingIdentity } from '../../lib/email-headers';
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
const DISMISSED_KEY = 'webmail:unsubscribe-dismissed:v1';
const dismissed = new Set<string>();
@@ -47,16 +47,18 @@ interface Props {
list: ListHeaders;
/** Dedupe key for dismissals (bare Message-ID, else the JMAP id). */
messageKey: string;
/** The message's JMAP account. A mailto unsubscribe still goes out from the user's own. */
jmapAccountId?: string;
}
/**
* List-Unsubscribe (RFC 2369 / RFC 8058) banner. An https one-click URL is
* POSTed directly; a plain http(s) URL opens in the in-app browser; a
* mailto: URL is sent through the account itself (handing it to the OS mail
* app goes nowhere for most users). Dismissals are remembered per Message-ID.
* mailto: URL is sent through the user's own account (handing it to the OS
* mail app goes nowhere for most users). Dismissals are remembered per
* Message-ID.
*/
export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Props) {
export function UnsubscribeBanner({ email, list, messageKey }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -110,13 +112,12 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
} else {
const fields = mailtoFields;
if (!fields) throw new Error('invalid mailto');
// Sent from the user's own account with one of its identities, also
// for a message in a shared account (see unsubscribeSendFolders).
const identity = findReceivingIdentity(identities, email) ?? identities[0];
if (!identity) throw new Error(t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
// Sent/Drafts of the account the mail is submitted from: the message's.
const scoped = mailboxesOfAccount(mailboxes, jmapAccountId);
const sent = scoped.find((m) => m.role === 'sent');
const drafts = scoped.find((m) => m.role === 'drafts');
if (!sent) throw new Error(t('email_composer.no_sent_folder', 'No Sent folder found'));
const { sentId, draftsId } = unsubscribeSendFolders(mailboxes);
if (!sentId) throw new Error(t('email_composer.no_sent_folder', 'No Sent folder found'));
await sendEmail(
{
from: [{ name: identity.name, email: identity.email }],
@@ -125,9 +126,9 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
textBody: fields.body ?? '',
},
identity.id,
sent.originalId ?? sent.id,
sentId,
undefined,
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
{ draftsMailboxId: draftsId },
);
void pruneEmailSubmissions();
}
+10
View File
@@ -186,4 +186,14 @@ describe('cid image hydration', () => {
expect(hydrated).toContain('data-cid="pic@x"');
expect(rewriteInlineImages(hydrated).html).toContain('src="cid:pic@x"');
});
// The composer builds the URI from the part's type, which the server
// returns verbatim.
it('leaves the placeholder when the data URL is not an image type plus base64', () => {
const { html } = rewriteCidImagesForEditor('<p><img src="cid:pic@x"></p>');
const hostile = 'data:image/x"><meta http-equiv=refresh content=0,url=https://evil.example/>;base64,QUJD';
const hydrated = replaceInlineImagePlaceholders(html, new Map([['pic@x', hostile]]));
expect(hydrated).toBe(html);
expect(hydrated).not.toContain('<meta');
});
});
+30 -5
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, shouldBlockEditorRemoteImages,
buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, isEditorNavigationAllowed, shouldBlockEditorRemoteImages,
} from '../editor-html';
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
@@ -28,18 +28,43 @@ describe('buildEditorHtml', () => {
it('emits no cooked-away backslashes in the script', () => {
// The page script is written escape-free on purpose; a backslash-free
// source cannot silently lose escapes to template-literal cooking.
const script = extractScript(buildEditorHtml(base));
// (The embedded content writes `<` as an escape, so it is left out here.)
const script = extractScript(buildEditorHtml({ ...base, initialHtml: 'x' }));
expect(script).not.toContain('\\');
});
it('embeds initial content and placeholder as JSON strings', () => {
it('embeds initial content as a JSON string and the placeholder as an attribute', () => {
const html = buildEditorHtml({
...base,
initialHtml: '<p>He said "hi" & left</p>',
placeholder: 'Say "something"…',
});
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>'));
expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`);
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>').replace(/</g, '\\u003c'));
expect(html).toContain('data-placeholder="Say &quot;something&quot;…"');
});
// A quoted original's kept <style> carried `</script/>`: the page script
// ended there, the rest of the reply ran as markup and a refresh meta took
// the editor to a data:text/html page.
it('keeps a quoted </script inside the page script', () => {
const quoted = '<p>hello</p><style>p{}</script/><img src=x onerror=alert(1)><meta http-equiv=refresh content=0;url=data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg== x=';
const initialHtml = `<p><br></p><div data-quoted-html>On X wrote:<blockquote>${quoted}</blockquote></div>\u2028`;
const html = buildEditorHtml({ ...base, initialHtml });
// The only end tag is the script's own.
expect(html.match(/<\/script/gi)).toHaveLength(1);
const script = extractScript(html);
expect(() => new Function(script)).not.toThrow();
const embedded = script.match(/var initial = (.*);/)![1];
expect(JSON.parse(embedded)).toBe(initialHtml);
expect(embedded).not.toContain('\u2028');
});
it('lets the editor load nothing but its own about:blank page', () => {
expect(isEditorNavigationAllowed('about:blank')).toBe(true);
expect(isEditorNavigationAllowed(undefined)).toBe(true);
expect(isEditorNavigationAllowed('data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==')).toBe(false);
expect(isEditorNavigationAllowed('about:srcdoc')).toBe(false);
expect(isEditorNavigationAllowed('https://example.com/')).toBe(false);
});
it('keeps the editor contenteditable and the bridge object wiring', () => {
+5 -1
View File
@@ -11,7 +11,7 @@ describe('parseAuthenticationResults', () => {
const r = parseAuthenticationResults(
'mx.example; spf=pass smtp.mailfrom=news.example; dkim=pass header.d=news.example header.s=s1; dmarc=pass header.from=news.example policy.dmarc=none; iprev=pass policy.iprev=1.2.3.4',
);
expect(r.spf).toEqual({ result: 'pass', domain: 'news.example' });
expect(r.spf).toEqual({ result: 'pass', identity: 'mailfrom', domain: 'news.example' });
expect(r.dkim).toEqual({ result: 'pass', domain: 'news.example', selector: 's1' });
expect(r.dmarc).toEqual({ result: 'pass', domain: 'news.example', policy: 'none' });
expect(r.iprev).toEqual({ result: 'pass', ip: '1.2.3.4' });
@@ -88,6 +88,10 @@ describe('getSenderVerification', () => {
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');
// Nor a HELO pass that is the only SPF result.
const only = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; dkim=none; dmarc=none');
expect(hasAlignedAuthenticationPass(only, 'a@bank.example')).toBe(false);
expect(getSenderVerification(only, 'a@bank.example')?.status).toBe('unverified');
});
});
+78
View File
@@ -77,6 +77,17 @@ describe('stripDangerousTags', () => {
expect(out).not.toMatch(/onerror/i);
});
// The composer embeds the sanitised quote in its inline script, where any
// `</script` ends the script whatever the CSS around it.
it('leaves no end tag inside a kept stylesheet or title', () => {
const out = stripDangerousTags(
'<p>hello</p><style>p{}</script/><img src=x onerror=alert(1)><meta http-equiv=refresh content=0;url=data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg== x=',
);
expect(out).not.toMatch(/<\/script/i);
expect(out).toContain('<style>p{}<\\/script/>');
expect(stripDangerousTags('<title>a</SCRIPT/>b</title>')).toBe('<title>a&lt;/SCRIPT/>b</title>');
});
it('ends comments where the browser does', () => {
expect(stripDangerousTags('a<!-- x --!><img src=x onerror=alert(1)>')).not.toMatch(/onerror/i);
expect(stripDangerousTags('<p>a</p><!--> <img src=x onerror=alert(1)>')).not.toMatch(/onerror/i);
@@ -176,6 +187,39 @@ describe('blockExternalResources', () => {
expect(html).not.toMatch(/<\/style>\s*<img/i);
});
// The sheet pass was a regex over the serialised markup, so a `<style>` in
// one attribute and a `</style>` in a later one made it "scrub" the
// attributes between them, decoding the CSS escapes into real quotes.
it('scrubs only real stylesheets, not text inside attribute values', () => {
const input = String.raw`<a title="<style>x\22 href=\22 javascript:alert(1)\22 y=\22 url(https://e.example/)">click</a><p title="</style>">z</p>`;
const { html } = prepareEmailHtml(input, { blockRemoteImages: true, isDark: false });
expect(html).not.toMatch(/href="javascript:/i);
expect(html).toContain(String.raw`<a title="<style>x\22 href=\22 javascript:alert(1)\22 y=\22 url(https://e.example/)">click</a>`);
});
it('cuts external URLs out of a sheet and leaves its escapes as written', () => {
const css = String.raw`a{background:url(https://x/)} b{background:\5c 75rl(https://y/)} c{background:\75rl(https://z/)} d{content:"\"\3c /style\3e"}`;
const { html, blocked } = blockExternalResources(`<style>${css}</style>`);
expect(blocked).toBe(true);
// `\5c 75rl(` is the text `\75rl(`, not a url(): written decoded it
// became one. The escaped quote stays escaped.
expect(html).toBe(String.raw`<style>a{background:url()} b{background:\5c 75rl(https://y/)} c{background:url()} d{content:"\"\3c /style\3e"}</style>`);
});
it('reads an escape past the last code point as U+FFFD instead of throwing', () => {
expect(decodeCssEscapes('\\FFFFFF')).toBe('\ufffd');
expect(() => blockExternalResources('<style>a{b:\\FFFFFF url(https://t/)}</style>')).not.toThrow();
});
it('finds images the parser reads as <img>, and entity-encoded URLs', () => {
expect(hasRemoteContent('<image src="https://tracker.example/p.gif">')).toBe(true);
expect(hasRemoteContent('<img src="&#104;ttps://tracker.example/p.gif">')).toBe(true);
expect(hasRemoteContent('<img srcset="&#104;ttps://tracker.example/p.gif 1x">')).toBe(true);
expect(hasRemoteContent('<video poster="&#x68;ttps://t/p.jpg"></video>')).toBe(true);
const { html } = blockExternalResources('<image src="https://tracker.example/p.gif">');
expect(html).toContain('data-blocked-src="https://tracker.example/p.gif"');
});
it('finds the image behind a stray quote, as the browser does', () => {
const { html, blocked } = blockExternalResources('<img x"=y src=https://t/a.png>');
expect(blocked).toBe(true);
@@ -240,6 +284,34 @@ describe('prepareEmailHtml', () => {
expect(res.html).toContain(`src="${TRANSPARENT_BLOCKED_PIXEL}"`);
});
// Stalwart (0.16 and 1.0) returns a part's Content-Type verbatim. A related
// part declared as this type, with bytes that are no known image format,
// used to put a live refresh meta into the reader (Android follows it
// without a tap).
it('never writes a hostile part type into the document', () => {
const type = 'image/x"><meta http-equiv=refresh content=0,url=https://evil.example/>';
const bytes = new TextEncoder().encode('ABC');
const uri = `data:${sniffImageMime(bytes, type)};base64,QUJD`;
for (const blockRemoteImages of [true, false]) {
const { html } = prepareEmailHtml('<p>hi</p><img src="cid:p1">', { blockRemoteImages, cidMap: { p1: uri }, isDark: false });
expect(html).not.toMatch(/<meta[^>]*refresh/i);
expect(html).not.toContain('evil.example');
expect(html).toContain(`<img src="${TRANSPARENT_BLOCKED_PIXEL}">`);
}
// A map value that is not an image type plus base64 is not used either.
const { html } = prepareEmailHtml('<img src="cid:p1">', { cidMap: { p1: 'data:image/png;base64,AA"><b>x</b>' } });
expect(html).not.toContain('<b>x</b>');
});
it('swaps cid references in src only, not in text or other attributes', () => {
const { html } = prepareEmailHtml(
'<img src="cid:abc" alt="cid:abc"><p>see cid:abc</p>',
{ cidMap: { abc: 'data:image/png;base64,AAA' } },
);
expect(html).toContain('<img src="data:image/png;base64,AAA" alt="cid:abc">');
expect(html).toContain('<p>see cid:abc</p>');
});
it('tightens Word/Outlook HTML and drops the gutter for full-bleed canvases in auto mode', () => {
const word = prepareEmailHtml('<div class="WordSection1"><p class="MsoNormal">x</p></div>');
expect(word.html).toContain('p.MsoNormal');
@@ -319,6 +391,12 @@ describe('sniffImageMime', () => {
expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'image/tiff')).toBe('image/tiff');
expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'application/octet-stream')).toBe('application/octet-stream');
});
it('uses only a plain image type from the part', () => {
expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'image/x"><meta http-equiv=refresh content=0,url=https://evil.example/>'))
.toBe('application/octet-stream');
expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'image/svg+xml; charset=utf-8')).toBe('image/svg+xml');
});
});
describe('reading width on a wide pane', () => {
+28 -1
View File
@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest';
import {
parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe,
parseUnsubscribeMailto, unsubscribeConfirmMessage,
parseUnsubscribeMailto, unsubscribeConfirmMessage, unsubscribeSendFolders,
} from '../unsubscribe';
import type { Mailbox } from '../../api/types';
describe('parseUnsubscribeUrls', () => {
it('prefers http over mailto and validates both', () => {
@@ -56,6 +57,10 @@ describe('parseUnsubscribeMailto', () => {
it('refuses a list of addresses or no address in the address part', () => {
expect(parseUnsubscribeMailto('mailto:a@list.example,b@corp.example')).toBeNull();
// Encoded separators decode into a second recipient.
expect(parseUnsubscribeMailto('mailto:a%2Cb@x.com')).toBeNull();
expect(parseUnsubscribeMailto('mailto:a%3Bb@x.com')).toBeNull();
expect(parseUnsubscribeMailto('mailto:%22a%2Cb%22@x.com')).toBeNull();
expect(parseUnsubscribeMailto('mailto:?to=a@list.example')).toBeNull();
expect(parseUnsubscribeMailto('mailto:?cc=a@list.example')).toBeNull();
});
@@ -80,3 +85,25 @@ describe('unsubscribeConfirmMessage', () => {
expect(unsubscribeConfirmMessage('Intro', null)).toBe('Intro');
});
});
describe('unsubscribeSendFolders', () => {
const mb = (id: string, role: string, shared?: string): Mailbox => ({
id: shared ? `${shared}:${id}` : id,
name: role,
role,
totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
myRights: {} as Mailbox['myRights'],
...(shared ? { originalId: id, accountId: shared, isShared: true } : { accountId: 'own', isShared: false }),
});
// The identities the app holds are the own account's, so the mail is
// created there too, also for a newsletter that reached a team mailbox.
it("uses the own account's Sent and Drafts even when a shared account's come first", () => {
const folders = unsubscribeSendFolders([
mb('t-sent', 'sent', 'team'), mb('t-drafts', 'drafts', 'team'),
mb('sent', 'sent'), mb('drafts', 'drafts'),
]);
expect(folders).toEqual({ sentId: 'sent', draftsId: 'drafts' });
expect(unsubscribeSendFolders([mb('t-sent', 'sent', 'team')])).toEqual({ sentId: undefined, draftsId: undefined });
});
});
+5 -2
View File
@@ -2,7 +2,7 @@
// what the webmail composer (`components/email/email-composer.tsx`) produces.
// All values are HTML-escaped before insertion into the document.
import { escapeHtml, stripDangerousTags } from './email-html';
import { escapeHtml, isInlineImageDataUri, stripDangerousTags } from './email-html';
import { buildQuoteHeader, type QuoteHeaderLabels } from './quote-header';
import type { TimeFormat } from '../stores/settings-store';
@@ -269,7 +269,10 @@ export function replaceInlineImagePlaceholders(
(full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => {
const cid = dq ?? sq ?? '';
const dataUrl = cidToDataUrl.get(cid);
if (!dataUrl) return full;
// The URI is built from the part's declared type, which the server
// returns verbatim: anything but an image type and base64 would land
// in the editor's markup as written.
if (!dataUrl || !isInlineImageDataUri(dataUrl)) return full;
const rest = (before + after).replace(/\ssrc=("[^"]*"|'[^']*')/gi, '').trim();
return `<img src="${dataUrl}" data-cid="${cid.replace(/"/g, '&quot;')}"${rest ? ' ' + rest : ''}>`;
},
+33 -4
View File
@@ -97,6 +97,35 @@ export const FOCUS_END_SOURCE = `function focusEnd(editor) {
sel.addRange(range);
}`;
/**
* Whether the editor WebView may load `url`: only its initial about:blank
* document (`source={{ html }}` loads as about:blank on both platforms, as
* in the reader). Every other URL, `about:srcdoc` and `data:` included, is
* refused: a `data:text/html` page reached through a refresh meta or a
* pasted link would replace the editor with a document of the sender's.
*/
export function isEditorNavigationAllowed(url: string | undefined): boolean {
return !url || url === 'about:blank';
}
/**
* JSON for a value written into an inline `<script>`. The HTML tokenizer
* ends the script at the first `</script` whatever the JS around it, so a
* quoted original carrying one (inside a kept `<style>`, say) used to close
* the editor's script early and run as markup. `<` is written as `<`;
* U+2028/2029 are escaped too, for engines that read them as line ends.
*/
export function jsonForScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
function escapeAttr(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function buildEditorHtml(opts: {
initialHtml: string;
placeholder: string;
@@ -112,10 +141,10 @@ export function buildEditorHtml(opts: {
}): string {
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
// are safely embedded (no template literal collision with the script body,
// and no `</script` that ends the script, see jsonForScript).
const initialJson = jsonForScript(initialHtml);
const nonce = randomHex(16);
const placeholderJson = JSON.stringify(placeholder);
return `<!DOCTYPE html>
<html>
@@ -173,7 +202,7 @@ export function buildEditorHtml(opts: {
</style>
</head>
<body>
<div id="editor" contenteditable="true" dir="auto" data-placeholder=${placeholderJson}></div>
<div id="editor" contenteditable="true" dir="auto" data-placeholder="${escapeAttr(placeholder)}"></div>
<script nonce="${nonce}">
(function () {
var editor = document.getElementById('editor');
+6 -5
View File
@@ -45,7 +45,7 @@ export interface DkimEntry {
}
export interface AuthenticationResults {
spf?: { result: SpfResult; domain?: string; all?: SpfEntry[] };
spf?: { result: SpfResult; identity?: SpfEntry['identity']; domain?: string; all?: SpfEntry[] };
/** The first signature's result; `all` lists every one when there are several. */
dkim?: DkimEntry & { all?: DkimEntry[] };
dmarc?: { result: DmarcResult; domain?: string; policy?: DmarcPolicy };
@@ -140,10 +140,10 @@ function alignedWith(fromDomain: string, candidate: string | undefined): boolean
* the visible sender. A HELO pass never does.
*/
function hasAlignedSpfPass(auth: AuthenticationResults, fromDomain: string): boolean {
if (auth.spf?.all) {
return auth.spf.all.some((entry) => entry.identity !== 'helo' && entry.result === 'pass' && alignedWith(fromDomain, entry.domain));
}
return auth.spf?.result === 'pass' && alignedWith(fromDomain, auth.spf.domain);
// With one SPF result there is no `all` list, and its identity is checked
// too: `spf=pass smtp.helo=<From domain>` alone used to count.
const entries = auth.spf?.all ?? (auth.spf ? [auth.spf] : []);
return entries.some((entry) => entry.identity === 'mailfrom' && entry.result === 'pass' && alignedWith(fromDomain, entry.domain));
}
/** A DKIM pass for a signing domain that aligns with the From domain. */
@@ -381,6 +381,7 @@ export function parseAuthenticationResults(headers: string | readonly string[]):
}
results.spf = {
result: primary.result,
...(primary.identity ? { identity: primary.identity } : {}),
domain: primary.domain,
...(spfResults.length > 1 ? { all: spfResults } : {}),
};
+156 -66
View File
@@ -167,6 +167,19 @@ const RAW_TEXT_TAGS = new Set([
'style', 'title', 'script', 'textarea', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript',
]);
/**
* Kept raw text with every `</` written so that no parser reads an end tag
* in it. Here only `</style` ends a sheet, but the sanitised markup is also
* embedded elsewhere: the composer seeds its editor with it inside an
* inline script, where a `</script` in a quoted sheet ended the script. In
* CSS `\/` is the same `/`; a title's text is RCDATA, where `&lt;` is `<`.
*/
function closeProofRawText(tag: string, text: string): string {
if (tag === 'style') return text.replace(/<\//g, '<\\/');
if (tag === 'title') return text.replace(/<\//g, '&lt;/');
return text;
}
// Attribute names written in a form that every later pass reads the same way.
const SAFE_ATTR_NAME = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
@@ -187,10 +200,15 @@ function serializeTag(name: string, attrs: ParsedAttr[], selfClosing: boolean):
* the browser keeps them) and returns the list to write, `null` to keep the
* attributes unchanged, or `false` to drop the tag. End tags are written
* without attributes; comments, doctypes and bogus comments are dropped.
* `rawText`, when given, rewrites the text of each kept raw-text element
* (a `<style>` sheet): the tokenizer knows where an element really starts,
* which a regex over the markup doesn't (it found `<style>` inside an
* attribute value).
*/
function rewriteTags(
html: string,
fn: (tag: string, attrs: ParsedAttr[]) => ParsedAttr[] | null | false,
rawText?: (tag: string, text: string) => string,
): string {
const n = html.length;
let out = '';
@@ -225,7 +243,10 @@ function rewriteTags(
close.lastIndex = i;
const m = close.exec(html);
const textEnd = m ? m.index : n;
if (result !== false) out += html.slice(i, textEnd);
if (result !== false) {
const text = html.slice(i, textEnd);
out += closeProofRawText(lower, rawText ? rawText(lower, text) : text);
}
i = textEnd;
}
continue;
@@ -290,8 +311,9 @@ function rewriteTags(
function mapTags(
html: string,
fn: (tag: string, attrs: ParsedAttr[]) => ParsedAttr[] | null,
rawText?: (tag: string, text: string) => string,
): string {
return rewriteTags(html, fn);
return rewriteTags(html, fn, rawText);
}
const FORBIDDEN_TAG_SET = new Set(FORBIDDEN_TAGS);
@@ -436,67 +458,107 @@ export function isExternalResourceUrl(value: string | null | undefined): boolean
*/
export function decodeCssEscapes(value: string): string {
return value.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_full, hex, char) => {
if (hex) {
const code = parseInt(hex, 16);
return code ? String.fromCodePoint(code) : '';
}
if (hex) return cssEscapeChar(parseInt(hex, 16));
return char ?? '';
});
}
// The character a hex escape stands for. Past U+10FFFF and surrogates are
// U+FFFD to CSS; String.fromCodePoint would throw on the former.
function cssEscapeChar(code: number): string {
if (!code) return '';
if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return '\ufffd';
return String.fromCodePoint(code);
}
/**
* `css` with its escapes decoded, plus for every decoded UTF-16 unit the
* index in `css` its source starts at (and `css.length` at the end), so a
* match in the decoded text can be cut out of the original.
*/
function decodeCssEscapesMapped(css: string): { text: string; from: number[] } {
const parts: string[] = [];
const from: number[] = [];
const escape = /\\([0-9a-fA-F]{1,6})\s?|\\(.)/y;
let i = 0;
while (i < css.length) {
const start = i;
let piece = css[i];
if (piece === '\\') {
escape.lastIndex = i;
const m = escape.exec(css);
if (m) {
piece = m[1] ? cssEscapeChar(parseInt(m[1], 16)) : m[2];
i += m[0].length;
} else {
i++;
}
} else {
i++;
}
parts.push(piece);
for (let k = 0; k < piece.length; k++) from.push(start);
}
from.push(css.length);
return { text: parts.join(''), from };
}
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^)]*?)\1\s*\)/gi;
const CSS_EXTERNAL_IMPORT = /@import\s+(['"])\s*(?:https?:)?\/\/[^'"]*\1[^;]*;?/gi;
/**
* Neutralise external references in CSS: background `url()`, `@font-face`
* sources and `@import`. Matching runs on the text with its escapes decoded,
* because the "css escape" tracker escapes the `url` keyword itself
* (`\75\72\6C(` -> `url(`), but only the matches are cut from the original:
* the rest keeps its escapes. Writing the decoded text back turned
* `\5c 75rl(` into `\75rl(`, which the engine reads as `url(` again, and an
* escaped quote into one that ends the string. Returns the original string
* when nothing external is present, so callers can detect a change by
* identity. (#457)
*/
export function stripExternalStyleSheetCss(css: string): string {
if (!css || !/url|@import|\\/i.test(css)) return css;
const { text, from } = decodeCssEscapesMapped(css);
const cuts: Array<{ start: number; end: number; with: string }> = [];
for (const m of text.matchAll(CSS_URL_PATTERN)) {
if (isExternalResourceUrl(m[2])) cuts.push({ start: m.index, end: m.index + m[0].length, with: 'url()' });
}
for (const m of text.matchAll(CSS_EXTERNAL_IMPORT)) {
cuts.push({ start: m.index, end: m.index + m[0].length, with: '' });
}
if (cuts.length === 0) return css;
cuts.sort((a, b) => a.start - b.start);
let out = '';
let at = 0;
for (const cut of cuts) {
const start = from[cut.start];
if (start < at) continue;
out += css.slice(at, start) + cut.with;
at = from[cut.end];
}
return out + css.slice(at);
}
/** True if any `url(...)` in a CSS string resolves to an external resource. */
export function styleHasExternalUrl(style: string): boolean {
let found = false;
style.replace(CSS_URL_PATTERN, (full, _q, inner) => {
if (isExternalResourceUrl(decodeCssEscapes(inner))) found = true;
return full;
});
return found;
return stripExternalStyleSheetCss(style) !== style;
}
/** Replace every external `url(...)` in a CSS string with an empty `url()`. */
export function stripExternalCssUrls(style: string): string {
return style.replace(CSS_URL_PATTERN, (full, _q, inner) =>
isExternalResourceUrl(decodeCssEscapes(inner)) ? 'url()' : full,
);
return stripExternalStyleSheetCss(style);
}
/**
* Neutralise external references in a full stylesheet (a kept `<style>`
* block): background `url()`, `@font-face` sources and `@import`. Escapes are
* decoded on the WHOLE block first because the "css escape" tracker escapes
* the `url` keyword itself (`\75\72\6C(` -> `url(`). Returns the original
* (escapes intact) when nothing external is present, so callers can detect a
* change by identity. (#457)
*/
export function stripExternalStyleSheetCss(css: string): string {
if (!css) return css;
const decoded = decodeCssEscapes(css);
if (!/url\(|@import/i.test(decoded)) return css;
let changed = false;
let result = decoded.replace(CSS_URL_PATTERN, (full, _q, inner: string) => {
if (isExternalResourceUrl(inner)) {
changed = true;
return 'url()';
}
return full;
});
result = result.replace(
/@import\s+(['"])\s*(?:https?:)?\/\/[^'"]*\1[^;]*;?/gi,
() => {
changed = true;
return '';
},
);
return changed ? result : css;
/** An attribute's URL as the browser reads it: character references decoded. */
function isExternalAttrUrl(value: string | null | undefined): boolean {
return !!value && isExternalResourceUrl(decodeUriEntities(value));
}
function srcsetHasExternalUrl(srcset: string): boolean {
return srcset
.split(',')
.some((candidate) => isExternalResourceUrl(candidate.trim().split(/\s+/)[0]));
.some((candidate) => isExternalAttrUrl(candidate.trim().split(/\s+/)[0]));
}
function appendStyle(attrs: ParsedAttr[], extra: string): void {
@@ -526,7 +588,7 @@ export interface BlockExternalResult {
export function blockExternalResources(html: string): BlockExternalResult {
let blocked = false;
let out = mapTags(html, (tag, attrs) => {
const out = mapTags(html, (tag, attrs) => {
let changed = false;
const get = (name: string) => attrs.find((a) => a.name.toLowerCase() === name);
const remove = (name: string) => {
@@ -534,9 +596,12 @@ export function blockExternalResources(html: string): BlockExternalResult {
if (i >= 0) attrs.splice(i, 1);
};
if (tag === 'img') {
// The parser reads an `<image>` start tag as `<img>`.
const isImg = tag === 'img' || tag === 'image';
if (isImg) {
const src = get('src');
if (src?.value && isExternalResourceUrl(src.value)) {
if (src?.value && isExternalAttrUrl(src.value)) {
attrs.push({ name: 'data-blocked-src', value: src.value.trim() });
src.value = TRANSPARENT_BLOCKED_PIXEL;
const alt = get('alt');
@@ -546,7 +611,7 @@ export function blockExternalResources(html: string): BlockExternalResult {
}
}
if (tag === 'img' || tag === 'source') {
if (isImg || tag === 'source') {
const srcset = get('srcset');
if (srcset?.value && srcsetHasExternalUrl(srcset.value)) {
attrs.push({ name: 'data-blocked-srcset', value: srcset.value });
@@ -557,7 +622,7 @@ export function blockExternalResources(html: string): BlockExternalResult {
if (tag === 'source' || tag === 'video' || tag === 'audio') {
const src = get('src');
if (src?.value && isExternalResourceUrl(src.value)) {
if (src?.value && isExternalAttrUrl(src.value)) {
attrs.push({ name: 'data-blocked-src', value: src.value.trim() });
remove('src');
changed = true;
@@ -566,7 +631,7 @@ export function blockExternalResources(html: string): BlockExternalResult {
if (tag === 'video' || tag === 'audio') {
const poster = get('poster');
if (poster?.value && isExternalResourceUrl(poster.value)) {
if (poster?.value && isExternalAttrUrl(poster.value)) {
attrs.push({ name: 'data-blocked-poster', value: poster.value.trim() });
remove('poster');
changed = true;
@@ -574,7 +639,7 @@ export function blockExternalResources(html: string): BlockExternalResult {
}
const bg = get('background');
if (bg?.value && isExternalResourceUrl(bg.value)) {
if (bg?.value && isExternalAttrUrl(bg.value)) {
attrs.push({ name: 'data-blocked-background', value: bg.value.trim() });
remove('background');
changed = true;
@@ -589,18 +654,14 @@ export function blockExternalResources(html: string): BlockExternalResult {
if (changed) blocked = true;
return changed ? attrs : null;
});
// <style> block CSS.
out = out.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)/gi, (_m, open, css: string, close) => {
}, (tag, css) => {
// <style> sheets, read where the tokenizer finds them: a regex over the
// markup also matched `<style>` inside an attribute value and rewrote
// the attributes after it.
if (tag !== 'style') return css;
const stripped = stripExternalStyleSheetCss(css);
if (stripped === css) return `${open}${css}${close}`;
blocked = true;
// The cleaned sheet comes back with its CSS escapes decoded, so an
// escaped `\3c /style\3e <img onerror=…>` would turn into markup that
// leaves the sheet. Write every `<` as the CSS escape again.
const cleaned = stripped.replace(/</g, '\\3c ');
return `${open}${cleaned}${close}`;
if (stripped !== css) blocked = true;
return stripped;
});
return { html: out, blocked };
@@ -752,10 +813,37 @@ export function extractCidRefs(html: string): string[] {
return Array.from(out);
}
/**
* A resolved inline image: an image type and nothing but base64 after it.
* The type comes from the part's Content-Type when the bytes aren't a known
* format, and the server hands that header over verbatim, quotes and `>`
* included.
*/
const INLINE_IMAGE_DATA_URI = /^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]*$/i;
export function isInlineImageDataUri(uri: string | null | undefined): boolean {
return !!uri && INLINE_IMAGE_DATA_URI.test(uri);
}
// Swap `cid:` references for their resolved data: URI inside src (and the
// legacy background) attributes only, through the tokenizer, on the
// sanitised markup. A string replace on the whole document also rewrote
// text and other attributes, and wrote a map value into the markup as is.
function replaceCidRefs(html: string, cidMap: Record<string, string>): string {
return html.replace(/\bcid:([^"'\s)>]+)/gi, (_m, ref: string) => {
const key = ref.replace(/^<|>$/g, '');
return cidMap[key] ?? TRANSPARENT_BLOCKED_PIXEL;
return mapTags(html, (_tag, attrs) => {
let changed = false;
for (const attr of attrs) {
if (attr.value === null) continue;
const name = asciiLower(attr.name);
if (name !== 'src' && name !== 'background') continue;
const raw = attr.value.trim();
const url = /^cid:/i.test(raw) ? raw : decodeUriEntities(raw);
if (!/^cid:/i.test(url)) continue;
const uri = cidMap[url.slice(4).replace(/^<|>$/g, '')];
attr.value = isInlineImageDataUri(uri) ? uri : TRANSPARENT_BLOCKED_PIXEL;
changed = true;
}
return changed ? attrs : null;
});
}
@@ -941,8 +1029,10 @@ export function sniffImageMime(bytes: Uint8Array, fallback?: string): string {
&& bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
) return 'image/webp';
if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp';
// The declared type goes into a data: URI, and the server returns the
// part's Content-Type verbatim: only a plain `image/<subtype>` is used.
const f = (fallback || '').toLowerCase().split(';')[0].trim();
if (f.startsWith('image/')) return f;
if (/^image\/[a-z0-9.+-]+$/.test(f)) return f;
return 'application/octet-stream';
}
+25 -1
View File
@@ -1,6 +1,9 @@
// List-Unsubscribe (RFC 2369 / RFC 8058) and mailto: parsing. Port of the
// relevant parts of the webmail's `lib/validation.ts`.
import type { Mailbox } from '../api/types';
import { mailboxesOfAccount } from './mailbox-tree';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(value: string): boolean {
@@ -151,7 +154,10 @@ export function parseUnsubscribeMailto(url: string): UnsubscribeMailto | null {
} catch {
to = addresses[0].trim();
}
if (!isValidEmail(to)) return null;
// One plain address after decoding too: `a%2Cb@x.example` is two
// recipients to the server, and a quote, bracket or separator has no place
// in an unsubscribe address.
if (!isValidEmail(to) || /[,;<>"()\\]/.test(to)) return null;
const subject = parsed.subject?.replace(/[\r\n]+/g, ' ').trim().slice(0, UNSUBSCRIBE_SUBJECT_MAX) || undefined;
const body = parsed.body?.slice(0, UNSUBSCRIBE_BODY_MAX) || undefined;
@@ -169,3 +175,21 @@ export function unsubscribeConfirmMessage(intro: string, fields: UnsubscribeMail
if (fields.body) lines.push('', fields.body);
return lines.join('\n');
}
/**
* Where a mailto unsubscribe is created and submitted: the user's own
* account, whose identities are the ones the app holds, with its Sent and
* Drafts (raw ids). Like the webmail, which sends it with the signed-in
* client and the identity that received the message. Creating it in a
* shared account's folders with an identity of the user's own account made
* the submission name an identity that account doesn't have.
*/
export function unsubscribeSendFolders(mailboxes: Mailbox[]): { sentId?: string; draftsId?: string } {
const own = mailboxesOfAccount(mailboxes, undefined);
const sent = own.find((m) => m.role === 'sent');
const drafts = own.find((m) => m.role === 'drafts');
return {
sentId: sent ? (sent.originalId ?? sent.id) : undefined,
draftsId: drafts ? (drafts.originalId ?? drafts.id) : undefined,
};
}
@@ -193,10 +193,13 @@ beforeEach(() => {
// Webmail 763732fdc / 3904af2cb: a search leaves Spam and Trash out unless
// "All folders" is picked, and one started inside them searches them.
describe('the default search scope', () => {
it("leaves every account's Trash and Junk out", async () => {
// Each account gets its own filter, still in one request: one request per
// account went past the servers' maxConcurrentRequests (4).
it("leaves every account's Trash and Junk out, in one request", async () => {
await searchDefault('zephyr');
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']);
expect(server.current!.requests).toHaveLength(1);
const queries = server.current!.callsOf('Email/query');
expect(queries.map(([, args]) => [args.accountId, args.filter])).toEqual([
['c', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
@@ -478,6 +481,25 @@ describe('opening and acting on "All folders" rows (#1082)', () => {
expect(useEmailStore.getState().pendingUndo?.items.map((it) => it.email.id).sort()).toEqual(['m1', 'o1']);
});
// "Move to Trash and mark as read": a message the server refused to move
// stays in its folder, so it must not be marked read either.
it('marks only the moved rows read when the server refuses one of them', async () => {
settings.deleteAction = 'trash-and-read';
await search('zephyr');
server.current!.refusing.set('team', new Set(['m2']));
await useEmailStore.getState()
.deleteEmailsBatch(['team:m1', 'team:m2'], 'trash', 'inbox')
.catch(() => undefined);
const seenPatches = server.current!.callsOf('Email/set')
.flatMap(([, a]) => Object.entries((a.update ?? {}) as Record<string, Record<string, unknown>>))
.filter(([, patch]) => Object.keys(patch).some((k) => k.startsWith('keywords')))
.map(([id]) => id);
expect(seenPatches).toEqual(['m1']);
expect(server.current!.accounts.team.find((e) => e.id === 'm1')!.keywords).toEqual({ $seen: true });
});
it('files the rest when one account refuses a whole move', async () => {
await search('zephyr');
server.current!.failing.set('team', 'forbidden');
+2 -1
View File
@@ -189,11 +189,12 @@ beforeEach(() => {
return { ...query, list: got.list, state: got.state, threads: [] };
});
mockQueryAcross.mockImplementation(async (
targets: Array<{ accountId?: string; position: number; sort: unknown }>,
targets: Array<{ accountId?: string; position: number; sort: unknown; filter?: Record<string, unknown> }>,
opts: Record<string, unknown>,
) => Promise.all(targets.map(async (target) => {
const page = await emailApi.queryEmailPage(undefined, {
...opts, position: target.position, sort: target.sort as never, accountId: target.accountId,
...(target.filter ? { filter: target.filter } : {}),
});
return { accountId: target.accountId, ok: true, total: page.total, list: page.list, threads: page.threads };
})));
@@ -44,6 +44,32 @@ describe('loadSharedTags', () => {
expect(useKeywordsStore.getState().sharedTags.team.tags).toHaveLength(1);
});
// Every list row reads its tag colours from this object: a new one on each
// re-read re-rendered the whole list.
it('keeps the same object when a re-read finds the same tags, and counts it as fresh', async () => {
vi.useFakeTimers();
try {
metadata.mockResolvedValue(TEAM_TAGS);
await useKeywordsStore.getState().loadSharedTags('team');
const before = useKeywordsStore.getState().sharedTags;
vi.advanceTimersByTime(3 * 60_000);
await useKeywordsStore.getState().loadSharedTags('team');
expect(metadata).toHaveBeenCalledTimes(2);
expect(useKeywordsStore.getState().sharedTags).toBe(before);
// The unchanged answer still counts as a fresh read.
vi.advanceTimersByTime(60_000);
await useKeywordsStore.getState().loadSharedTags('team');
expect(metadata).toHaveBeenCalledTimes(2);
metadata.mockResolvedValue({ tags: { tags: { urgent: { label: 'Now', color: 'red' } } } });
await useKeywordsStore.getState().loadSharedTags('team', { force: true });
expect(useKeywordsStore.getState().sharedTags).not.toBe(before);
expect(useKeywordsStore.getState().sharedTags.team.tags[0].label).toBe('Now');
} finally {
vi.useRealTimers();
}
});
it('drops an answer that arrives after the user switched to another login', async () => {
let resolve!: (v: unknown) => void;
metadata.mockReturnValue(new Promise((r) => { resolve = r; }));
+20 -15
View File
@@ -742,20 +742,19 @@ async function fetchSpanningPage(
const threads = !useSettingsStore.getState().disableThreading;
const skipTrashAndJunk = searchSkipsTrashAndJunk(state.searchQuery, state.filters, openRoleOf(state));
const run = async (accounts: Array<string | undefined>) => {
const targets = await Promise.all(accounts.map(async (accountId) => ({
accountId,
position: loaded[accountId ?? primary] ?? 0,
sort: await resolveSort(state, accountId),
})));
if (!skipTrashAndJunk) return queryEmailPagesAcrossAccounts(targets, { limit, filter, threads });
// Each account leaves its own Trash and Junk out, so each gets its own
// filter (and request).
const pages = await Promise.all(targets.map((target) => {
const ids = trashAndJunkIds(state.mailboxes, target.accountId);
const scoped = ids.length > 0 ? andFilter(filter, { inMailboxOtherThan: ids }) : filter;
return queryEmailPagesAcrossAccounts([target], { limit, filter: scoped, threads });
const targets = await Promise.all(accounts.map(async (accountId) => {
// Each account leaves its own Trash and Junk out, so each gets its own
// filter; they still share requests (one request per account went
// past the servers' maxConcurrentRequests with a few shared accounts).
const ids = skipTrashAndJunk ? trashAndJunkIds(state.mailboxes, accountId) : [];
return {
accountId,
position: loaded[accountId ?? primary] ?? 0,
sort: await resolveSort(state, accountId),
filter: ids.length > 0 ? andFilter(filter, { inMailboxOtherThan: ids }) : filter,
};
}));
return pages.map((p) => p[0]);
return queryEmailPagesAcrossAccounts(targets, { limit, filter, threads });
};
let pages = await run(spannedAccounts(state.mailboxes));
const refused = pages.filter((p) => !p.ok && isUnsupportedSort(p.error));
@@ -2401,6 +2400,8 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
const items: UndoEntry['items'] = [];
const gone: Array<{ email: Email; key: string }> = [];
const keyOf = (accountId: string | undefined, id: string) => `${accountId ?? ''}:${id}`;
// Rows a server refused to file (see the runs below), by `keyOf`.
const refused = new Set<string>();
const copies: Array<{ accountId?: string; emails: Email[] }> = [];
let missing: string | null = null;
let refreshFolders = false;
@@ -2490,7 +2491,12 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
});
if (unread.length > 0) {
runs.push({
run: () => patchKeywordsForEmails(ids(unread), { $seen: true }, accountId),
// Runs after the move: a row the server refused to move stays
// where it is, unread, as in the single-account delete.
run: async () => {
const moved = ids(unread).filter((id) => !refused.has(keyOf(accountId, id)));
if (moved.length > 0) await patchKeywordsForEmails(moved, { $seen: true }, accountId);
},
accountId,
ids: ids(unread),
keeps: false,
@@ -2537,7 +2543,6 @@ async function fileAcrossAccounts(action: SpanningAction, targets: Email[], toMa
// account may refuse what another takes (a read-only share): the refused
// rows stay, the others are filed, and the first refusal is reported once
// the list shows the rest. Before, one refused message kept every row.
const refused = new Set<string>();
const unpatched = new Set<string>();
let refusal: unknown = null;
if (ops.length > 0) {
+18 -3
View File
@@ -78,6 +78,14 @@ export function currentTagsLogin(): string {
}
const sharedInflight = new Map<string, Promise<void>>();
// When each login's account last answered: an unchanged answer keeps the
// entry (and its `loadedAt`), but still counts as fresh.
const sharedReadAt = new Map<string, number>();
function sameTags(a: KeywordDef[], b: KeywordDef[]): boolean {
return a.length === b.length
&& a.every((t, i) => t.id === b[i].id && t.label === b[i].label && t.color === b[i].color);
}
function persist(keywords: KeywordDef[]): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => {
@@ -94,10 +102,11 @@ export const useKeywordsStore = create<KeywordsState>((set, get) => ({
const login = currentTagsLogin();
if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve();
const current = get().sharedTags[accountId];
if (!options?.force && current?.login === login && Date.now() - current.loadedAt < SHARED_TAGS_STALE_MS) {
const key = `${login}|${accountId}`;
const readAt = Math.max(current?.loadedAt ?? 0, sharedReadAt.get(key) ?? 0);
if (!options?.force && current?.login === login && Date.now() - readAt < SHARED_TAGS_STALE_MS) {
return Promise.resolve();
}
const key = `${login}|${accountId}`;
const running = sharedInflight.get(key);
if (running) return running;
const run = (async () => {
@@ -106,10 +115,16 @@ export const useKeywordsStore = create<KeywordsState>((set, get) => ({
if (currentTagsLogin() !== login) return;
// A failed read (or none to make) keeps the last good tags.
if (!entries) return;
const tags = parseSharedTags(entries[SHARED_TAGS_KEY]);
sharedReadAt.set(key, Date.now());
// Unchanged tags keep the entry, and with it every row's tag colours:
// a new object re-rendered each row of the list on every re-read.
const prev = get().sharedTags[accountId];
if (prev?.login === login && sameTags(prev.tags, tags)) return;
set((s) => ({
sharedTags: {
...s.sharedTags,
[accountId]: { login, tags: parseSharedTags(entries[SHARED_TAGS_KEY]), loadedAt: Date.now() },
[accountId]: { login, tags, loadedAt: Date.now() },
},
}));
})().finally(() => sharedInflight.delete(key));