From 3f9dc7c030d7ef903ea98d1e80206b0e6b357483 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Mon, 24 Aug 2026 15:59:15 -0700 Subject: [PATCH] Keep the share email's button link within what mail clients tolerate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Open Puter" link names every item in the mail, capped at twenty. But a single item's parameter is ~150 characters once the owner, uuid and an encoded name are in it, so twenty of them run to several kilobytes — past the ~2000 characters where older mail clients cut a URL off or stop making it clickable — and the count alone couldn't do what its comment promised for the mail's primary button. Add a length budget beside the count: items go in, in digest order, only while the whole link stays under it, so the button always works and the first items are the ones highlighted. A single item always fits. --- .../services/share/shareDeepLink.test.ts | 21 +++++++++ src/backend/services/share/shareDeepLink.ts | 43 ++++++++++++------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/backend/services/share/shareDeepLink.test.ts b/src/backend/services/share/shareDeepLink.test.ts index e2643ae91..c5693c4a0 100644 --- a/src/backend/services/share/shareDeepLink.test.ts +++ b/src/backend/services/share/shareDeepLink.test.ts @@ -22,6 +22,7 @@ import { maskedSharePath, ownerFromSharePath, SHARE_DEEP_LINK_ITEMS_LIMIT, + SHARE_DEEP_LINK_MAX_LENGTH, shareDeepLink, sharedViewLink, shareTargetLink, @@ -137,6 +138,26 @@ describe('sharedViewLink', () => { ).searchParams.getAll('shared'); expect(shared).toEqual(paths.slice(0, SHARE_DEEP_LINK_ITEMS_LIMIT)); }); + + // Twenty ordinary names already run to several kilobytes once encoded, so + // the count alone is no guard; the link itself has to stay short enough. + it('stops adding items before the link outgrows what mail clients tolerate', () => { + const paths = Array.from( + { length: SHARE_DEEP_LINK_ITEMS_LIMIT }, + (_, i) => `/alice/${UID}/${'quarterly report '.repeat(8)}${i}.pdf`, + ); + const link = sharedViewLink('https://puter.com', paths); + expect(link.length).toBeLessThanOrEqual(SHARE_DEEP_LINK_MAX_LENGTH); + const shared = new URL(link).searchParams.getAll('shared'); + // Only a leading run made it — the first items, none skipped. + expect(shared.length).toBeGreaterThan(1); + expect(shared.length).toBeLessThan(paths.length); + expect(shared).toEqual(paths.slice(0, shared.length)); + // The next item would not have fit. + expect( + link.length + `&shared=${encodeURIComponent(paths[shared.length])}`.length, + ).toBeGreaterThan(SHARE_DEEP_LINK_MAX_LENGTH); + }); }); describe('shareTargetLink', () => { diff --git a/src/backend/services/share/shareDeepLink.ts b/src/backend/services/share/shareDeepLink.ts index 02acee468..ffa77aa0b 100644 --- a/src/backend/services/share/shareDeepLink.ts +++ b/src/backend/services/share/shareDeepLink.ts @@ -54,12 +54,20 @@ export const maskedSharePath = (target: ShareTarget): string | null => { }; /** - * Items one link will highlight. Past this the link still opens Shared, just - * without picking the rest out — a query string of several kilobytes is where - * mail clients start truncating or refusing to make it clickable. + * Items one link will highlight, at most. Past this the link still opens + * Shared, just without picking the rest out. */ export const SHARE_DEEP_LINK_ITEMS_LIMIT = 20; +/** + * How long a link may run, in characters. Somewhere past two thousand, older + * mail clients cut a URL off or stop making it clickable — and this is the + * button — so items are added only while the whole link stays within this. A + * single item always fits: a username, a uuid and a filename come to a few + * hundred, and it takes long names for the count above to matter first. + */ +export const SHARE_DEEP_LINK_MAX_LENGTH = 2000; + /** * A link that opens the recipient's Shared view with `paths` highlighted, once * they are signed in. Only masked paths travel — each one's second segment is @@ -67,18 +75,23 @@ export const SHARE_DEEP_LINK_ITEMS_LIMIT = 20; * with the first. With no paths the link still lands on Shared. */ export const sharedViewLink = (origin: string, paths: string[]): string => { - const base = origin.replace(/\/+$/, ''); - const unique = [...new Set(paths)].slice(0, SHARE_DEEP_LINK_ITEMS_LIMIT); - const query = - unique.length === 0 - ? `${SHARE_DEEP_LINK_PARAM}=` - : unique - .map( - (path) => - `${SHARE_DEEP_LINK_PARAM}=${encodeURIComponent(path)}`, - ) - .join('&'); - return `${base}/?${query}`; + const base = `${origin.replace(/\/+$/, '')}/?`; + // The first items that fit, in order — never a later one over an + // earlier, so what is highlighted reads as the top of the list. + const params: string[] = []; + let length = base.length; + for (const path of new Set(paths)) { + if (params.length === SHARE_DEEP_LINK_ITEMS_LIMIT) break; + const param = `${SHARE_DEEP_LINK_PARAM}=${encodeURIComponent(path)}`; + const added = param.length + (params.length === 0 ? 0 : '&'.length); + if (length + added > SHARE_DEEP_LINK_MAX_LENGTH) break; + params.push(param); + length += added; + } + return ( + base + + (params.length === 0 ? `${SHARE_DEEP_LINK_PARAM}=` : params.join('&')) + ); }; /** A link that opens `path`: the Shared view with that one item highlighted. */