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 => ` + + + + +
+${label} +
`; + +/** One row per sender: "**alice** shared report.txt". */ +const shareList = ` + +{{#each shares}} + + + +{{/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 => ` +<!DOCTYPE html> +<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<meta http-equiv="X-UA-Compatible" content="IE=edge"> +<meta name="x-apple-disable-message-reformatting"> +<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no"> +<meta name="color-scheme" content="light dark"> +<meta name="supported-color-schemes" content="light dark"> +<title>${opts.title} + + + + +
${opts.preheader}${' ‌'.repeat(30)}
+
+ + + + + +
+ +`; + 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}} - - - - {{/each}} -
- {{this.sender}} shared {{this.what}} -
-

- 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}} +
Unsubscribe 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}} - - - - {{/each}} -
- {{this.sender}} shared {{this.what}} -
-

- 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());