mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
Merge branch 'fx/read2' into feat/stalwart-1.0
This commit is contained in:
@@ -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
@@ -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`]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 "something"…"');
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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</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="https://tracker.example/p.gif">')).toBe(true);
|
||||
expect(hasRemoteContent('<img srcset="https://tracker.example/p.gif 1x">')).toBe(true);
|
||||
expect(hasRemoteContent('<video poster="https://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', () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '"')}"${rest ? ' ' + rest : ''}>`;
|
||||
},
|
||||
|
||||
+33
-4
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -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
@@ -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 `<` is `<`.
|
||||
*/
|
||||
function closeProofRawText(tag: string, text: string): string {
|
||||
if (tag === 'style') return text.replace(/<\//g, '<\\/');
|
||||
if (tag === 'title') return text.replace(/<\//g, '</');
|
||||
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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user