diff --git a/src/api/__tests__/email.test.ts b/src/api/__tests__/email.test.ts index be14a65f..213962d9 100644 --- a/src/api/__tests__/email.test.ts +++ b/src/api/__tests__/email.test.ts @@ -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).mockReturnValueOnce(16); + mockRequest.mockImplementation(async (calls: Array<[string, Record, 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: [ diff --git a/src/api/email.ts b/src/api/email.ts index 75fedc13..c9504dc4 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -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; } 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`]); diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 3d1444ef..b961d380 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -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(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)} /> ); diff --git a/src/components/email/UnsubscribeBanner.tsx b/src/components/email/UnsubscribeBanner.tsx index 75e7d244..a41205ac 100644 --- a/src/components/email/UnsubscribeBanner.tsx +++ b/src/components/email/UnsubscribeBanner.tsx @@ -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(); @@ -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(); } diff --git a/src/lib/__tests__/compose-html.test.ts b/src/lib/__tests__/compose-html.test.ts index 3874b6f1..ae0bdcc2 100644 --- a/src/lib/__tests__/compose-html.test.ts +++ b/src/lib/__tests__/compose-html.test.ts @@ -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('

'); + const hostile = 'data:image/x">;base64,QUJD'; + const hydrated = replaceInlineImagePlaceholders(html, new Map([['pic@x', hostile]])); + expect(hydrated).toBe(html); + expect(hydrated).not.toContain(' { 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: '

He said "hi" & left

', placeholder: 'Say "something"…', }); - expect(html).toContain(JSON.stringify('

He said "hi" & left

')); - expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`); + expect(html).toContain(JSON.stringify('

He said "hi" & left

').replace(/ carried ``: 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 { + const quoted = '

hello

` 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`click

z

`; + const { html } = prepareEmailHtml(input, { blockRemoteImages: true, isDark: false }); + expect(html).not.toMatch(/href="javascript:/i); + expect(html).toContain(String.raw`click`); + }); + + 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(``); + 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``); + }); + + it('reads an escape past the last code point as U+FFFD instead of throwing', () => { + expect(decodeCssEscapes('\\FFFFFF')).toBe('\ufffd'); + expect(() => blockExternalResources('')).not.toThrow(); + }); + + it('finds images the parser reads as , and entity-encoded URLs', () => { + expect(hasRemoteContent('')).toBe(true); + expect(hasRemoteContent('')).toBe(true); + expect(hasRemoteContent('')).toBe(true); + expect(hasRemoteContent('')).toBe(true); + const { html } = blockExternalResources(''); + 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(''); 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">'; + const bytes = new TextEncoder().encode('ABC'); + const uri = `data:${sniffImageMime(bytes, type)};base64,QUJD`; + for (const blockRemoteImages of [true, false]) { + const { html } = prepareEmailHtml('

hi

', { blockRemoteImages, cidMap: { p1: uri }, isDark: false }); + expect(html).not.toMatch(/]*refresh/i); + expect(html).not.toContain('evil.example'); + expect(html).toContain(``); + } + // A map value that is not an image type plus base64 is not used either. + const { html } = prepareEmailHtml('', { cidMap: { p1: 'data:image/png;base64,AA">x' } }); + expect(html).not.toContain('x'); + }); + + it('swaps cid references in src only, not in text or other attributes', () => { + const { html } = prepareEmailHtml( + 'cid:abc

see cid:abc

', + { cidMap: { abc: 'data:image/png;base64,AAA' } }, + ); + expect(html).toContain('cid:abc'); + expect(html).toContain('

see cid:abc

'); + }); + it('tightens Word/Outlook HTML and drops the gutter for full-bleed canvases in auto mode', () => { const word = prepareEmailHtml('

x

'); 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">')) + .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', () => { diff --git a/src/lib/__tests__/unsubscribe.test.ts b/src/lib/__tests__/unsubscribe.test.ts index 0b57a0cc..1746157b 100644 --- a/src/lib/__tests__/unsubscribe.test.ts +++ b/src/lib/__tests__/unsubscribe.test.ts @@ -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 }); + }); +}); diff --git a/src/lib/compose-html.ts b/src/lib/compose-html.ts index 9460d19b..96313e15 100644 --- a/src/lib/compose-html.ts +++ b/src/lib/compose-html.ts @@ -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 ``; }, diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index f3d2ad41..84dba7e6 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -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 `