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}}
- | {{this.sender}} shared ${SHARE_ITEM_LIST} |
+ {{this.sender}} shared ${SHARE_ITEM_LIST}{{#if this.via}} via {{this.via}}{{/if}} |
{{/each}}
@@ -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 () => {