From 577693bf8432d318a20a5676f55a6356a7d3d767 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 15 Sep 2026 12:15:30 -0400 Subject: [PATCH 1/2] feat: name the issuing app in share emails, and cut the digest window to 5s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an app shares on a user's behalf, the mail now says so — the digest line reads 'alice shared report.txt via Mail App', preferring the app's title, then its name, then its index_url host. Both the per-item links and the 'Open Puter' button carry a new shared_app query param (the app's name, or its uid) so the GUI can match the share back to the app; the button only carries it when the whole digest came from one app. Plain shares are byte-for-byte unchanged. Also drops SHARE_EMAIL_BATCH_SECONDS from 30 to 5: the window only has to fold one gesture's worth of calls, and every second there is a second the common single-share email arrives late. Closes PUT-1798. --- src/backend/clients/email/templates.ts | 6 +- .../share/ShareNotificationService.ts | 83 +++++++++++++++++-- .../services/share/shareDeepLink.test.ts | 31 +++++++ src/backend/services/share/shareDeepLink.ts | 26 ++++-- src/backend/services/share/shareEmail.test.ts | 60 +++++++++++++- .../services/share/shareNotifyTitle.test.ts | 29 +++++++ .../services/share/shareNotifyTitle.ts | 10 ++- src/docs/src/rate-limits-and-quotas.md | 2 +- 8 files changed, 230 insertions(+), 17 deletions(-) diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts index 0eaf36730..ab5e6cd19 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -184,7 +184,7 @@ const SHARE_LIST_ROW = ` {{#each shares}} - + {{/each}}
{{this.sender}} shared ${SHARE_ITEM_LIST}{{this.sender}} shared ${SHARE_ITEM_LIST}{{#if this.via}} via {{this.via}}{{/if}}
@@ -456,7 +456,7 @@ immediately

Shared with you on Puter: {{#each shares}} - - {{this.sender}} shared {{this.what}} + - {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}} {{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}} {{/if}}{{/each}}{{/each}} @@ -494,7 +494,7 @@ immediately

Shared with you on Puter: {{#each shares}} - - {{this.sender}} shared {{this.what}} + - {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}} {{/each}} There's no Puter account for {{email}} yet. Create one with this diff --git a/src/backend/services/share/ShareNotificationService.ts b/src/backend/services/share/ShareNotificationService.ts index c78cee54b..189ae0bcc 100644 --- a/src/backend/services/share/ShareNotificationService.ts +++ b/src/backend/services/share/ShareNotificationService.ts @@ -63,7 +63,7 @@ export const SHARE_NOTIFY_RECIPIENT_DAILY_LIMIT = 50; * span goes as a single message. Email can't be rewritten the way the in-app * notification can, so it gets the grouped wording by waiting instead. */ -export const SHARE_EMAIL_BATCH_SECONDS = 30; +export const SHARE_EMAIL_BATCH_SECONDS = 5; // Long enough to list, claim and send; short enough that a crashed flusher // doesn't strand the digest. @@ -139,6 +139,15 @@ interface ShareNotificationTarget { name: string; } +/** + * The issuing app: `label` is what the mail says, `match` what the link + * carries. + */ +interface IssuingApp { + label: string | null; + match: string; +} + /** * A queued send's durable form: persisted to KV so it survives the node that * queued it and is visible to every other node's flush. @@ -155,6 +164,8 @@ interface DigestEntryRecord { names: string[]; /** As `names`, plus links. Absent on records queued before this shipped. */ items?: DigestItem[]; + /** The app the shares came through, when one did. */ + app?: IssuingApp | null; /** Arrival order — KV lists by key, which is a uuid and says nothing. */ queuedAt: number; } @@ -261,6 +272,7 @@ export class ShareNotificationService extends PuterService { const issuerId = actor.user?.id; const issuer = actor.user?.username; if (typeof issuerId !== 'number') return; + const app = await this.#issuingApp(actor); const counts = new Map(); const named = new Map(); @@ -271,7 +283,7 @@ export class ShareNotificationService extends PuterService { issuerId, )) { counts.set(holderId, (counts.get(holderId) ?? 0) + 1); - const item = this.#digestItem(share); + const item = this.#digestItem(share, app); if (item) { const items = named.get(holderId) ?? []; if (items.length < DIGEST_NAMES_PER_SENDER) items.push(item); @@ -309,6 +321,7 @@ export class ShareNotificationService extends PuterService { count, named.get(holderId) ?? [], interrupt, + app, ); } catch (err) { console.warn( @@ -321,7 +334,7 @@ export class ShareNotificationService extends PuterService { ); try { - await this.#emailInvites(actor, shares); + await this.#emailInvites(actor, shares, app); } catch (err) { console.warn('[share-notify] could not email invites:', err); } @@ -634,6 +647,7 @@ export class ShareNotificationService extends PuterService { count: number, items: DigestItem[], mayOpen: boolean, + app: IssuingApp | null, ): Promise { // Explicitly false, not falsy: unset means on. if (this.config.share_email_notifications === false) { @@ -677,21 +691,61 @@ export class ShareNotificationService extends PuterService { count, items, mayOpen, + app, ); } + /** The app this call shares through (label: title, then name, then host). */ + async #issuingApp(actor: Actor): Promise { + const uid = actor.effectiveApp?.uid; + if (!uid) return null; + try { + const app = (await this.stores.app.getByUid(uid)) as { + name?: string | null; + title?: string | null; + index_url?: string | null; + } | null; + if (!app) return { label: null, match: uid }; + return { + label: + app.title || + app.name || + this.#originOf(app.index_url) || + null, + match: app.name || uid, + }; + } catch (err) { + // The mail still goes; it just can't name the app. + console.warn('[share-notify] could not resolve issuing app:', err); + return { label: null, match: uid }; + } + } + + /** `https://draw.example.com/v2/` → `draw.example.com`. */ + #originOf(indexUrl: string | null | undefined): string | null { + if (!indexUrl) return null; + try { + return new URL(indexUrl).host || null; + } catch { + return null; + } + } + /** * One named, linked item for the digest. Built from the uuid and owner, not * `share.path` — that is the owner's real path here, not the recipient's to * see. Both forms name the owner first, which is where it comes from. */ - #digestItem(share: ResolvedShare): DigestItem | null { + #digestItem( + share: ResolvedShare, + app: IssuingApp | null, + ): DigestItem | null { if (!share.name) return null; const path = this.#targetPath(share); if (!path) return { name: share.name }; return { name: share.name, - link: shareDeepLink(this.#appLink(), path), + link: shareDeepLink(this.#appLink(), path, app?.match), path, }; } @@ -730,6 +784,7 @@ export class ShareNotificationService extends PuterService { count: number, items: DigestItem[], mayOpen: boolean, + app: IssuingApp | null, ): Promise { // A digest is one email, so the budget is spent opening one, not per // share — anything arriving while one collects joins it for free. @@ -746,6 +801,7 @@ export class ShareNotificationService extends PuterService { // can flush this entry; `items` is what this one reads. names: items.map((item) => item.name), items, + app, queuedAt: Date.now(), }; await this.stores.kv.set({ @@ -880,8 +936,17 @@ export class ShareNotificationService extends PuterService { record.sender, record.count, this.#recordItems(record), + record.app?.label, ); } + // Name the app on the button only when the whole mail is its doing. + const matches = new Set( + claimed.map(({ record }) => record.app?.match ?? null), + ); + const linkApp = + matches.size === 1 + ? (matches.values().next().value ?? null) + : null; const [{ record: first }] = claimed; console.log('[share-notify] sending digest:', { key, @@ -905,6 +970,7 @@ export class ShareNotificationService extends PuterService { link: sharedViewLink( this.#appLink(), digestItemPaths(entries), + linkApp, ), // The template composes the unsubscribe URL from // the origin, so `?` and `=` stay literal instead @@ -963,7 +1029,11 @@ export class ShareNotificationService extends PuterService { * `share_email_notifications` says — there is no Puter inbox to use instead * — but still budgeted: an invite reaches someone who never asked for it. */ - async #emailInvites(actor: Actor, shares: ResolvedShare[]): Promise { + async #emailInvites( + actor: Actor, + shares: ResolvedShare[], + app: IssuingApp | null, + ): Promise { if (!this.config.email) return; const issuerId = actor.user?.id; if (typeof issuerId !== 'number') return; @@ -1007,6 +1077,7 @@ export class ShareNotificationService extends PuterService { count, items, mayOpen, + app, ); } catch (err) { console.warn('[share-notify] invite email failed:', err); diff --git a/src/backend/services/share/shareDeepLink.test.ts b/src/backend/services/share/shareDeepLink.test.ts index fe590143a..f19049fcf 100644 --- a/src/backend/services/share/shareDeepLink.test.ts +++ b/src/backend/services/share/shareDeepLink.test.ts @@ -161,6 +161,37 @@ describe('sharedViewLink', () => { expect(shared).toEqual(paths.slice(0, SHARE_DEEP_LINK_ITEMS_LIMIT)); }); + it('carries the issuing app as its own parameter, or not at all', () => { + const path = `/alice/${UID}/a.txt`; + const link = sharedViewLink('https://puter.com', [path], 'draw-app'); + expect(link).toBe( + `https://puter.com/?shared=${encodeURIComponent(path)}&shared_app=draw-app`, + ); + expect(new URL(link).searchParams.get('shared_app')).toBe('draw-app'); + // No app, no parameter — for a null the same as for an omission. + expect(sharedViewLink('https://puter.com', [path], null)).toBe( + sharedViewLink('https://puter.com', [path]), + ); + expect(shareDeepLink('https://puter.com', path, 'draw-app')).toBe(link); + }); + + it('encodes an app value that would otherwise break the query string', () => { + const link = sharedViewLink('https://puter.com', [], 'a&b=c'); + expect(new URL(link).searchParams.get('shared_app')).toBe('a&b=c'); + }); + + it('reserves room for the app before spending the length cap on items', () => { + const paths = Array.from( + { length: SHARE_DEEP_LINK_ITEMS_LIMIT }, + (_, i) => `/alice/${UID}/${'quarterly report '.repeat(8)}${i}.pdf`, + ); + const app = `app-${'x'.repeat(60)}`; + const link = sharedViewLink('https://puter.com', paths, app); + expect(link.length).toBeLessThanOrEqual(SHARE_DEEP_LINK_MAX_LENGTH); + // The app survives however many items wanted the space. + expect(new URL(link).searchParams.get('shared_app')).toBe(app); + }); + // 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', () => { diff --git a/src/backend/services/share/shareDeepLink.ts b/src/backend/services/share/shareDeepLink.ts index 193cb3be4..f4bea1a36 100644 --- a/src/backend/services/share/shareDeepLink.ts +++ b/src/backend/services/share/shareDeepLink.ts @@ -27,6 +27,9 @@ /** The query parameter the GUI routes on. */ export const SHARE_DEEP_LINK_PARAM = 'shared'; +/** The issuing app (its `name`, or uid), so the GUI can match the share to it. */ +export const SHARE_DEEP_LINK_APP_PARAM = 'shared_app'; + export interface ShareTarget { /** The entry's own name, which the masked path's last segment must be. */ name: string; @@ -74,13 +77,22 @@ export const SHARE_DEEP_LINK_MAX_LENGTH = 2000; * they are signed in. Only masked paths travel — each one's second segment is * the uuid, so a rename is recoverable and there is no second copy to disagree * with the first. With no paths the link still lands on Shared. + * + * `app` rides along as `shared_app`, its length reserved up front. */ -export const sharedViewLink = (origin: string, paths: string[]): string => { +export const sharedViewLink = ( + origin: string, + paths: string[], + app?: string | null, +): string => { const base = `${origin.replace(/\/+$/, '')}/?`; + const appParam = app + ? `&${SHARE_DEEP_LINK_APP_PARAM}=${encodeURIComponent(app)}` + : ''; // 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; + let length = base.length + appParam.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)}`; @@ -92,13 +104,17 @@ export const sharedViewLink = (origin: string, paths: string[]): string => { } return ( base + - (params.length === 0 ? `${SHARE_DEEP_LINK_PARAM}=` : params.join('&')) + (params.length === 0 ? `${SHARE_DEEP_LINK_PARAM}=` : params.join('&')) + + appParam ); }; /** A link that opens `path`: the Shared view with that one item highlighted. */ -export const shareDeepLink = (origin: string, path: string): string => - sharedViewLink(origin, [path]); +export const shareDeepLink = ( + origin: string, + path: string, + app?: string | null, +): string => sharedViewLink(origin, [path], app); /** The link for a target, or `null` when it isn't addressable. */ export const shareTargetLink = ( diff --git a/src/backend/services/share/shareEmail.test.ts b/src/backend/services/share/shareEmail.test.ts index a367590b2..ba20c53d5 100644 --- a/src/backend/services/share/shareEmail.test.ts +++ b/src/backend/services/share/shareEmail.test.ts @@ -32,6 +32,8 @@ */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeActor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; const BOOT_TIMEOUT_MS = 120_000; @@ -250,7 +252,11 @@ describe('share email', () => { const username = `se${uniqueSuffix()}`; const signup = await fetch(new URL('/signup', env.origin), { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + // Each signup is its own person; don't share one signup budget. + 'user-agent': `share-email-suite/${username}`, + }, body: JSON.stringify({ username, email, @@ -500,6 +506,9 @@ describe('share email', () => { expect(mail.text).toContain(link); // The owner's real path is theirs alone; only the mask travels. expect(mail.html).not.toContain(`/${owner.username}/deeplink`); + // No app issued this, so nothing claims it. + expect(mail.html).not.toContain('shared_app='); + expect(mail.html).not.toContain(' via '); }); it('links every file when several are shared at once', async () => { @@ -534,6 +543,55 @@ describe('share email', () => { expect(mail.text).toContain(`Open Puter: ${href}`); }); + it('names the issuing app and stamps it on the links', async () => { + const owner = env.users.user; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const file = await makeFile(owner, 'via-app'); + // An app of the owner's, with reach over the file, sharing as them. + const user = await env.server.stores.user.getByUsername(owner.username); + const actor = makeActor({ user: user! }); + const app = await env.server.stores.app.create( + { + name: `mail-app-${crypto.randomUUID().slice(0, 8)}`, + title: 'Mail App', + index_url: 'https://mail-app.test/', + }, + { ownerUserId: user!.id }, + ); + await runWithContext({ actor }, () => + env.server.services.permission.grantUserAppPermission( + actor, + app.uid, + `fs:${file.uid}:read`, + ), + ); + const token = await env.server.services.auth.getUserAppToken( + actor, + app.uid, + ); + + await shareWith({ token }, recipient.email, [{ uid: file.uid }]); + + const mail = await waitForMail({ to: recipient.email }); + expect(mail.html).toContain('via Mail App'); + expect(mail.text).toContain(`shared ${file.name} via Mail App`); + const href = openPuterHref(mail.html); + expect(new URL(href!).searchParams.get('shared_app')).toBe(app.name); + const masked = `/${owner.username}/${file.uid}/${file.name}`; + expect(mail.html).toContain( + `?shared=${encodeURIComponent(masked)}&shared_app=${app.name}`, + ); + + // The invite names the app too; with no links, no parameter to ride. + const invitee = uninvitedAddress(); + await shareWith({ token }, invitee, [{ uid: file.uid }]); + const invite = await waitForMail({ to: invitee }); + expect(invite.html).toContain('via Mail App'); + expect(invite.html).not.toContain('shared_app='); + }); + // Nothing to route to yet, so the names stay plain and the call to action // is still "create an account". it('does not link the files in an invite', async () => { diff --git a/src/backend/services/share/shareNotifyTitle.test.ts b/src/backend/services/share/shareNotifyTitle.test.ts index eee45ec2c..4e5c68045 100644 --- a/src/backend/services/share/shareNotifyTitle.test.ts +++ b/src/backend/services/share/shareNotifyTitle.test.ts @@ -246,4 +246,33 @@ describe('email digests', () => { }, ]); }); + + it('carries the issuing app onto the line, as "via"', () => { + const merged = mergeDigestEntry([], 'alice', 1, [item('a.txt')], 'Draw'); + expect(merged).toEqual([ + { username: 'alice', count: 1, items: [item('a.txt')], app: 'Draw' }, + ]); + expect(digestLines(merged)).toMatchObject([ + { sender: 'alice', what: 'a.txt', via: 'Draw' }, + ]); + // No app, no `via` key — the template's {{#if}} must see nothing. + expect(digestLines([{ username: 'bob', count: 1, items: [] }])[0]) + .not.toHaveProperty('via'); + }); + + it('drops the app when one sender arrives through two sources', () => { + const viaApp = mergeDigestEntry([], 'alice', 1, [item('a.txt')], 'Draw'); + // Same app again: attribution holds. + expect( + mergeDigestEntry(viaApp, 'alice', 1, [item('b.txt')], 'Draw')[0].app, + ).toBe('Draw'); + // A plain share, or another app, makes the line nobody's to claim. + expect(mergeDigestEntry(viaApp, 'alice', 1, [])[0].app).toBeNull(); + expect( + mergeDigestEntry(viaApp, 'alice', 1, [], 'Notes')[0].app, + ).toBeNull(); + // A different sender keeps their own attribution. + const two = mergeDigestEntry(viaApp, 'bob', 1, [], 'Notes'); + expect(two.map((entry) => entry.app)).toEqual(['Draw', 'Notes']); + }); }); diff --git a/src/backend/services/share/shareNotifyTitle.ts b/src/backend/services/share/shareNotifyTitle.ts index 047fdbbb2..02db16257 100644 --- a/src/backend/services/share/shareNotifyTitle.ts +++ b/src/backend/services/share/shareNotifyTitle.ts @@ -136,6 +136,8 @@ export interface DigestEntry { count: number; /** Named items, newest last. */ items: DigestItem[]; + /** The app the shares came through, when they all came through one. */ + app?: string | null; } /** How many item names one digest line spells out before counting the rest. */ @@ -147,6 +149,7 @@ export const mergeDigestEntry = ( username: string | undefined, count: number, items: DigestItem[] = [], + app?: string | null, ): DigestEntry[] => { const name = username || 'Someone'; const merged = entries.map((entry) => ({ @@ -157,9 +160,11 @@ export const mergeDigestEntry = ( if (existing) { existing.count += count; existing.items.push(...items); + // One sender through two sources is nobody's app to name. + if ((existing.app ?? null) !== (app ?? null)) existing.app = null; return merged; } - merged.push({ username: name, count, items: [...items] }); + merged.push({ username: name, count, items: [...items], app: app ?? null }); return merged; }; @@ -206,6 +211,8 @@ export interface DigestLine { lead: string; items: DigestItem[]; trail: string; + /** The issuing app's display name, rendered as "via {{via}}". */ + via?: string; } /** One rendered line per sender: who, and what they shared. */ @@ -230,5 +237,6 @@ export const digestLines = (entries: DigestEntry[]): DigestLine[] => lead, items: named, trail, + ...(entry.app ? { via: entry.app } : {}), }; }); diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 5ad859edb..e13e36626 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -179,7 +179,7 @@ Separately, the notification and email that tell a recipient about a share are b Recipients are emailed by default and opt out with the unsubscribe link the mail carries; a deployment can turn share email off entirely with `share_email_notifications: false`. -Over these, **the share still succeeds** — only the announcement is dropped. The recipient's notification is kept up to date either way, and folds several senders into one ("alice and bob shared 5 items with you"), so nothing is lost; it just doesn't interrupt them again. Emails are additionally batched: everything triggered for one recipient within a 90-second window goes as a single digest message. Recipients can also refuse shares outright — from one sender, or from everyone — which fails that sender's `share` call with `recipient_not_accepting_shares`. Both are managed from **Settings → Security → Blocked people**. +Over these, **the share still succeeds** — only the announcement is dropped. The recipient's notification is kept up to date either way, and folds several senders into one ("alice and bob shared 5 items with you"), so nothing is lost; it just doesn't interrupt them again. Emails are additionally batched: everything triggered for one recipient within a 5-second window goes as a single digest message. Recipients can also refuse shares outright — from one sender, or from everyone — which fails that sender's `share` call with `recipient_not_accepting_shares`. Both are managed from **Settings → Security → Blocked people**. ### Teams and teams From 0bd64584411779f49ce7ae0ec0f49b974ab1cf4a Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 15 Sep 2026 18:56:38 -0400 Subject: [PATCH 2/2] test: pin the mixed-digest button link carrying no app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #3871: the flush-level rule — shared_app on the button only when every entry came through one app — had no direct coverage. An app share and a plain share folded into one digest now prove the line and item link keep their attribution while the button stays clean. --- src/backend/services/share/shareEmail.test.ts | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/backend/services/share/shareEmail.test.ts b/src/backend/services/share/shareEmail.test.ts index ba20c53d5..41ac52a51 100644 --- a/src/backend/services/share/shareEmail.test.ts +++ b/src/backend/services/share/shareEmail.test.ts @@ -543,19 +543,18 @@ describe('share email', () => { expect(mail.text).toContain(`Open Puter: ${href}`); }); - it('names the issuing app and stamps it on the links', async () => { - const owner = env.users.user; - const recipient = await signUpAndConfirm(uninvitedAddress()); - sent = []; - - const file = await makeFile(owner, 'via-app'); - // An app of the owner's, with reach over the file, sharing as them. + /** An app of `owner`'s with reach over `file`, and a token to share as them. */ + const makeSharingApp = async ( + owner: { username: string }, + file: { uid: string }, + title: string, + ) => { const user = await env.server.stores.user.getByUsername(owner.username); const actor = makeActor({ user: user! }); const app = await env.server.stores.app.create( { name: `mail-app-${crypto.randomUUID().slice(0, 8)}`, - title: 'Mail App', + title, index_url: 'https://mail-app.test/', }, { ownerUserId: user!.id }, @@ -571,6 +570,16 @@ describe('share email', () => { actor, app.uid, ); + return { ...app, token }; + }; + + it('names the issuing app and stamps it on the links', async () => { + const owner = env.users.user; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const file = await makeFile(owner, 'via-app'); + const { token, ...app } = await makeSharingApp(owner, file, 'Mail App'); await shareWith({ token }, recipient.email, [{ uid: file.uid }]); @@ -592,6 +601,33 @@ describe('share email', () => { expect(invite.html).not.toContain('shared_app='); }); + it('keeps the app off the button when the digest is not all its doing', async () => { + const appSender = env.users.user; + const plainSender = env.users.admin; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const viaApp = await makeFile(appSender, 'mixed-app'); + const plain = await makeFile(plainSender, 'mixed-plain'); + const app = await makeSharingApp(appSender, viaApp, 'Mixer'); + await withDigestWindow(env, DIGEST_WINDOW_SECONDS, async () => { + await shareWith({ token: app.token }, recipient.email, [ + { uid: viaApp.uid }, + ]); + await shareWith(plainSender, recipient.email, [{ uid: plain.uid }]); + }); + + const mail = await waitForMail({ to: recipient.email }); + await sleep(SETTLE_MS); + expect(mailTo(recipient.email)).toHaveLength(1); + // The app's own line and item link keep their attribution... + expect(mail.html).toContain('via Mixer'); + expect(mail.html).toContain(`&shared_app=${app.name}`); + // ...but the button speaks for the whole mail, so it names no app. + const href = openPuterHref(mail.html); + expect(new URL(href!).searchParams.get('shared_app')).toBeNull(); + }); + // Nothing to route to yet, so the names stay plain and the call to action // is still "create an account". it('does not link the files in an invite', async () => {