diff --git a/src/backend/clients/email/EmailClient.ts b/src/backend/clients/email/EmailClient.ts
index 814df6909..13b342c81 100644
--- a/src/backend/clients/email/EmailClient.ts
+++ b/src/backend/clients/email/EmailClient.ts
@@ -22,7 +22,11 @@ import handlebars, { template } from 'handlebars';
import nodemailer from 'nodemailer';
import type { IConfig } from '../../types';
import { PuterClient } from '../types';
-import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
+import {
+ EMAIL_TEMPLATES,
+ type EmailTemplate,
+ type EmailTemplateName,
+} from './templates';
/** Attachment shape passed through to the underlying transport. */
export interface EmailAttachment {
@@ -64,6 +68,8 @@ export type EmailValidator = (email: string) => Promise | boolean;
interface CompiledTemplate {
subject: ReturnType;
html: ReturnType;
+ /** Present only for templates that define a plain-text part. */
+ text?: ReturnType;
}
// -- Clean-email rules ------------------------------------------------
@@ -155,13 +161,15 @@ export class EmailClient extends PuterClient {
/**
* Render a template and send it to `to`. `options.replyTo` sets the
* Reply-To header (e.g. so a recipient can respond to the originator of the
- * message rather than the no-reply From address).
+ * message rather than the no-reply From address); `options.headers` adds
+ * extra message headers (e.g. List-Unsubscribe). Templates with a `text`
+ * part go out as multipart/alternative.
*/
async send(
to: string,
template: T,
values: Record = {},
- options: { replyTo?: string } = {},
+ options: { replyTo?: string; headers?: Record } = {},
): Promise {
const compiled = this.compiledTemplates[template];
if (!compiled) {
@@ -173,7 +181,9 @@ export class EmailClient extends PuterClient {
to,
subject: compiled.subject(values),
html: compiled.html(values),
+ ...(compiled.text ? { text: compiled.text(values) } : {}),
...(options.replyTo ? { replyTo: options.replyTo } : {}),
+ ...(options.headers ? { headers: options.headers } : {}),
});
}
@@ -285,7 +295,9 @@ export class EmailClient extends PuterClient {
}
private compileTemplates(): void {
- for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) {
+ for (const [name, template] of Object.entries(
+ EMAIL_TEMPLATES,
+ )) {
this.compiledTemplates[name as EmailTemplateName] = {
// Subjects are plain-text headers: HTML-escaping would put
// literal entities in front of the recipient (& etc.).
@@ -296,6 +308,15 @@ export class EmailClient extends PuterClient {
noEscape: true,
}),
html: handlebars.compile(dedent(template.html)),
+ // The text part is not HTML either: escaped entities would
+ // reach the reader as literal "&".
+ ...(template.text
+ ? {
+ text: handlebars.compile(dedent(template.text), {
+ noEscape: true,
+ }),
+ }
+ : {}),
};
}
}
diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts
index db4ed39a1..f54017461 100644
--- a/src/backend/clients/email/templates.ts
+++ b/src/backend/clients/email/templates.ts
@@ -29,8 +29,129 @@
export interface EmailTemplate {
subject: string;
html: string;
+ /**
+ * Plain-text alternative part. When present the message goes out as
+ * multipart/alternative — better deliverability, and a readable fallback
+ * for clients (and filters) that don't render HTML. Compiled with
+ * `noEscape`: it is not HTML, so entities would be read literally.
+ */
+ text?: string;
}
+// -- Share email layout -------------------------------------------------
+//
+// The sharing notifications are the emails most often sent to addresses that
+// never asked to hear from Puter, so they carry the full email-client
+// boilerplate: a complete document, table layout, inline styles, hidden
+// preview text, and dark-mode overrides. Interpolations sit at column 0 so
+// `dedent` (applied at compile time) leaves the markup untouched.
+
+const FONT =
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
+
+/**
+ * Bulletproof-enough CTA: padding lives on the link so the whole button is
+ * clickable, `mso-padding-alt` gives Outlook the same visual size (there only
+ * the label is clickable, which is the accepted degradation).
+ */
+const shareCta = (label: string, href: string): string => `
+`;
+
+/** One row per sender: "**alice** shared report.txt". */
+const shareList = `
+
+{{#each shares}}
+
+|
+{{this.sender}} shared {{this.what}}
+ |
+
+{{/each}}
+
`;
+
+const shareEmailShell = (opts: {
+ /** Preview line clients show next to the subject; hidden in the body. */
+ preheader: string;
+ /** `` — read by screen readers and a few clients. */
+ title: string;
+ /** The card's inner HTML: tables and inline styles only. */
+ content: string;
+ /** Small print under the card: why they got this, how to opt out. */
+ footer: string;
+}): string => `
+
+
+
+
+
+
+
+
+
+
+${opts.title}
+
+
+
+
+${opts.preheader}${' '.repeat(30)}
+
+
+
+
+
+
+|
+Puter
+ |
+
+
+|
+${opts.content}
+ |
+
+
+|
+${opts.footer}
+ |
+
+
+ |
+
+
+
+
+`;
+
export const EMAIL_TEMPLATES = {
'approved-for-listing': {
subject: '🎉 Your app has been approved for listing!',
@@ -190,57 +311,76 @@ immediately
*/
file_shared_with_you: {
subject: '{{subject_line}}',
- html: `
-
-
Hi {{recipient}},
-
Shared with you on Puter:
-
- {{#each shares}}
-
- |
- {{this.sender}} shared {{this.what}}
- |
-
- {{/each}}
-
-
- Open Puter
-
-
Sincerely,
Puter
- {{#if unsubscribe_uuid}}
-
- Don't want these? Unsubscribe.
-
- {{/if}}
-
+ html: shareEmailShell({
+ title: '{{subject_line}}',
+ preheader: 'It’s waiting for you in your Puter account.',
+ content: `
+Hi {{recipient}},
+Here’s what was shared with you on Puter:
+${shareList}
+${shareCta('Open Puter', '{{link}}')}
+You’ll find everything under “Shared with me” in your files.
`,
+ footer: `
+
+You’re receiving this because someone shared items with your Puter account.
+{{#if unsubscribe_uuid}}
+
from Puter email notifications.
+{{/if}}
+
`,
+ }),
+ text: `
+Hi {{recipient}},
+
+Here's what was shared with you on Puter:
+
+{{#each shares}}
+* {{this.sender}} shared {{this.what}}
+{{/each}}
+
+Open Puter: {{link}}
+
+You're receiving this because someone shared items with your Puter account.
+{{#if unsubscribe_uuid}}Unsubscribe: {{link}}/unsubscribe?user_uuid={{unsubscribe_uuid}}{{/if}}
`,
},
// The only way to reach someone with no account. Same digest shape.
+ // Careful: "Open Puter" must not appear here — it is how the holder
+ // digest is told apart from this one.
file_shared_invite: {
subject: '{{subject_line}}',
- html: `
-
-
Hi there,
-
Shared with you on Puter:
-
- {{#each shares}}
-
- |
- {{this.sender}} shared {{this.what}}
- |
-
- {{/each}}
-
-
- You don't have a Puter account for this address yet. Create one with
- {{email}} and confirm it, and what was shared will be
- waiting for you.
-
-
- Create your account
-
-
Sincerely,
Puter
-
+ html: shareEmailShell({
+ title: '{{subject_line}}',
+ preheader:
+ 'Create a free Puter account with this email address to view it.',
+ content: `
+Hi there,
+Here’s what was shared with you on Puter:
+${shareList}
+There’s no Puter account for {{email}} yet. Create a free account with this exact address, confirm it, and everything shared with you will be waiting in your files.
+${shareCta('Create your account', '{{link}}')}
+New to Puter? It’s the open-source Internet OS — your files, apps, and games in one place, from any device.
`,
+ footer: `
+
+You’re receiving this because a Puter user shared something with this email address.
+
If you weren’t expecting it, you can safely ignore this email.
+
`,
+ }),
+ text: `
+Hi there,
+
+Here's what was shared with you on Puter:
+
+{{#each shares}}
+* {{this.sender}} shared {{this.what}}
+{{/each}}
+
+There's no Puter account for {{email}} yet. Create a free account with this
+exact address, confirm it, and everything shared with you will be waiting.
+
+Create your account: {{link}}
+
+You're receiving this because a Puter user shared something with this email
+address. If you weren't expecting it, you can safely ignore this email.
`,
},
share_by_username: {
diff --git a/src/backend/controllers/static/StaticPagesController.test.ts b/src/backend/controllers/static/StaticPagesController.test.ts
index 447aafa8b..d8f53ed94 100644
--- a/src/backend/controllers/static/StaticPagesController.test.ts
+++ b/src/backend/controllers/static/StaticPagesController.test.ts
@@ -278,6 +278,57 @@ describe('StaticPagesController GET /unsubscribe', () => {
});
});
+// RFC 8058 one-click: mailbox providers POST to the List-Unsubscribe URL
+// with nobody in a browser, so the route answers plain text, not the page.
+describe('StaticPagesController POST /unsubscribe (one-click)', () => {
+ it('flips unsubscribed=1 and answers plain text, not a page', async () => {
+ const user = await makeUser({ unsubscribed: 0 });
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'post',
+ '/unsubscribe',
+ makeReq({ query: { user_uuid: user.uuid } }),
+ res,
+ );
+ expect(captured.statusCode).toBe(200);
+ expect(captured.contentType).toBe('text/plain');
+ expect(String(captured.body)).not.toContain(' {
+ const user = await makeUser({ unsubscribed: 1 });
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'post',
+ '/unsubscribe',
+ makeReq({ query: { user_uuid: user.uuid } }),
+ res,
+ );
+ expect(captured.statusCode).toBe(200);
+ });
+
+ it('rejects a missing user_uuid', async () => {
+ const { res, captured } = makeRes();
+ await callRoute('post', '/unsubscribe', makeReq({}), res);
+ expect(captured.statusCode).toBe(400);
+ });
+
+ it('rejects an unknown user', async () => {
+ const { res, captured } = makeRes();
+ await callRoute(
+ 'post',
+ '/unsubscribe',
+ makeReq({
+ query: { user_uuid: '00000000-0000-0000-0000-000000000000' },
+ }),
+ res,
+ );
+ expect(captured.statusCode).toBe(404);
+ });
+});
+
// ── /confirm-email-by-token ─────────────────────────────────────────
describe('StaticPagesController GET /confirm-email-by-token', () => {
diff --git a/src/backend/controllers/static/StaticPagesController.ts b/src/backend/controllers/static/StaticPagesController.ts
index 8565990ee..8940fd8a8 100644
--- a/src/backend/controllers/static/StaticPagesController.ts
+++ b/src/backend/controllers/static/StaticPagesController.ts
@@ -234,6 +234,39 @@ export class StaticPagesController extends PuterController {
},
);
+ // RFC 8058 one-click unsubscribe: mailbox providers POST to the URL
+ // the List-Unsubscribe header carries, with no person in a browser —
+ // so this answers plain text, not the HTML page above.
+ router.post(
+ '/unsubscribe',
+ { rateLimit: TOKEN_LINK_LIMIT },
+ async (req, res) => {
+ const userUuid =
+ typeof req.query.user_uuid === 'string'
+ ? req.query.user_uuid
+ : undefined;
+ if (!userUuid) {
+ res.status(400)
+ .type('text/plain')
+ .send('user_uuid is required');
+ return;
+ }
+
+ const user = await this.stores.user.getByUuid(userUuid);
+ if (!user) {
+ res.status(404).type('text/plain').send('User not found.');
+ return;
+ }
+
+ if (!user.unsubscribed) {
+ await this.stores.user.update(user.id, {
+ unsubscribed: 1,
+ });
+ }
+ res.type('text/plain').send('Unsubscribed.');
+ },
+ );
+
// -- /confirm-email-by-token ---------------------------------
router.get(
'/confirm-email-by-token',
diff --git a/src/backend/services/share/ShareNotificationService.ts b/src/backend/services/share/ShareNotificationService.ts
index 23f216b64..7a83c20d1 100644
--- a/src/backend/services/share/ShareNotificationService.ts
+++ b/src/backend/services/share/ShareNotificationService.ts
@@ -721,6 +721,12 @@ export class ShareNotificationService extends PuterService {
try {
if (first.kind === 'holder') {
+ // RFC 8058 one-click headers: providers surface their
+ // native "Unsubscribe" affordance instead of the spam
+ // button, and POST here without a person in a browser.
+ const unsubscribeUrl = first.recipientUuid
+ ? `${this.#appLink()}/unsubscribe?user_uuid=${first.recipientUuid}`
+ : null;
await this.clients.email.send(
first.to,
'file_shared_with_you',
@@ -733,6 +739,15 @@ export class ShareNotificationService extends PuterService {
// stay literal instead of escaping to `=`.
unsubscribe_uuid: first.recipientUuid ?? null,
},
+ unsubscribeUrl
+ ? {
+ headers: {
+ 'List-Unsubscribe': `<${unsubscribeUrl}>`,
+ 'List-Unsubscribe-Post':
+ 'List-Unsubscribe=One-Click',
+ },
+ }
+ : {},
);
} else {
await this.clients.email.send(
diff --git a/src/backend/services/share/shareEmail.test.ts b/src/backend/services/share/shareEmail.test.ts
index 9ca1a1c71..ded6de0b8 100644
--- a/src/backend/services/share/shareEmail.test.ts
+++ b/src/backend/services/share/shareEmail.test.ts
@@ -45,6 +45,10 @@ interface SentEmail {
to: string;
subject: string;
html: string;
+ /** The plain-text alternative part, when the template defines one. */
+ text?: string;
+ /** Extra message headers (List-Unsubscribe et al.). */
+ headers?: Record;
}
const uniqueSuffix = (): string =>
@@ -116,11 +120,19 @@ describe('share email', () => {
beforeEach(() => {
sent = [];
vi.spyOn(env.server.clients.email, 'sendRaw').mockImplementation(
- async (options: { to: string; subject: string; html?: string }) => {
+ async (options: {
+ to: string;
+ subject: string;
+ html?: string;
+ text?: string;
+ headers?: Record;
+ }) => {
sent.push({
to: options.to,
subject: options.subject,
html: options.html ?? '',
+ text: options.text,
+ headers: options.headers,
});
return null;
},
@@ -285,6 +297,12 @@ describe('share email', () => {
// is dead on any self-host that doesn't run on the default port.
expect(mail.html).toContain(`href="${env.origin}"`);
+ // The plain-text part carries the same story, and there is no
+ // unsubscribe header — an invitee has no account to unsubscribe.
+ expect(mail.text).toContain(file.name);
+ expect(mail.text).toContain(invitee);
+ expect(mail.headers).toBeUndefined();
+
// Nobody else hears about it — least of all the sender.
await sleep(SETTLE_MS);
expect(sent).toHaveLength(1);
@@ -493,6 +511,34 @@ describe('share email', () => {
);
});
+ it('carries a plain-text part and one-click unsubscribe headers', async () => {
+ const owner = env.users.user;
+ const recipient = await signUpAndConfirm(uninvitedAddress());
+ sent = [];
+
+ const file = await makeFile(owner, 'alt-part');
+ await shareWith(owner, recipient.email, [{ uid: file.uid }]);
+ const mail = await waitForMail({ to: recipient.email });
+
+ // multipart/alternative: the text part mirrors the html for clients
+ // (and filters) that don't read HTML.
+ expect(mail.text).toContain(`${owner.username} shared ${file.name}`);
+ expect(mail.text).toContain(env.origin);
+
+ // RFC 8058 one-click: the provider-level unsubscribe affordance,
+ // pointing at the same URL the body's link uses.
+ const row = await env.server.stores.user.getByUsername(
+ recipient.username,
+ );
+ expect(mail.headers?.['List-Unsubscribe']).toBe(
+ `<${env.origin}/unsubscribe?user_uuid=${row!.uuid}>`,
+ );
+ expect(mail.headers?.['List-Unsubscribe-Post']).toBe(
+ 'List-Unsubscribe=One-Click',
+ );
+ expect(mail.text).toContain(`/unsubscribe?user_uuid=${row!.uuid}`);
+ });
+
it('sends nothing to a recipient who has blocked the sender', async () => {
const owner = env.users.user;
const recipient = await signUpAndConfirm(uninvitedAddress());