diff --git a/src/backend/clients/email/EmailClient.test.ts b/src/backend/clients/email/EmailClient.test.ts index 71db9ea14..2c3ab18f5 100644 --- a/src/backend/clients/email/EmailClient.test.ts +++ b/src/backend/clients/email/EmailClient.test.ts @@ -245,6 +245,139 @@ describe('EmailClient — template rendering', () => { }); }); +describe('EmailClient — share notification templates', () => { + /** Captures both parts of a send without touching the transport. */ + const renderShare = async ( + template: 'file_shared_with_you' | 'file_shared_invite', + values: Record, + ) => { + const client = startClient(); + const captured: { html: string; text: string } = { html: '', text: '' }; + vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => { + captured.html = options.html ?? ''; + captured.text = options.text ?? ''; + return null; + }); + await client.send('user@example.test', template, values); + return captured; + }; + + const HOLDER = { + recipient: 'alice', + subject_line: 'bob shared notes.md with you', + shares: [ + { sender: 'bob', what: 'notes.md' }, + { sender: 'carol', what: '3 items — a.txt, b.txt, +1 more' }, + ], + link: 'https://puter.test', + unsubscribe_uuid: null, + }; + + it('sends a plain-text alternative beside the html', async () => { + const { html, text } = await renderShare( + 'file_shared_with_you', + HOLDER, + ); + + expect(html).toContain(''); + // Every sender reaches both parts, so a text-only client loses nothing. + for (const part of [html, text]) { + expect(part).toContain('bob'); + expect(part).toContain('notes.md'); + expect(part).toContain('carol'); + expect(part).toContain('+1 more'); + } + expect(text).not.toContain('<'); + }); + + it('escapes item names in the html and leaves them raw in the text', async () => { + const { html, text } = await renderShare('file_shared_with_you', { + ...HOLDER, + shares: [{ sender: 'bob', what: 'r&d "notes".md' }], + }); + + expect(html).toContain('r&d "notes".md'); + expect(text).toContain('r&d "notes".md'); + }); + + it('renders as a responsive single column with no remote assets', async () => { + const { html } = await renderShare('file_shared_with_you', HOLDER); + + expect(html).toContain( + '', + ); + expect(html).toContain('@media only screen and (max-width: 600px)'); + expect(html).toContain('@media (prefers-color-scheme: dark)'); + expect(html).toContain('max-width: 600px'); + // Images are the one thing a client can refuse to load, so the design + // does without them — the layout can't depend on a blocked asset. + expect(html).not.toContain('Open Puter'); + expect(text).toContain('Open Puter: https://puter.test'); + }); + + it('separates senders without a rule above the first', async () => { + const { html } = await renderShare('file_shared_with_you', HOLDER); + + // One rule between two senders, none leading the list. + expect(html.split('border-top: 1px solid').length - 1).toBe(1); + }); + + it('offers the unsubscribe link only to a recipient who has an account', async () => { + const withAccount = await renderShare('file_shared_with_you', { + ...HOLDER, + unsubscribe_uuid: 'a-uuid', + }); + expect(withAccount.html).toContain( + 'href="https://puter.test/unsubscribe?user_uuid=a-uuid"', + ); + expect(withAccount.text).toContain( + 'https://puter.test/unsubscribe?user_uuid=a-uuid', + ); + + const anonymous = await renderShare('file_shared_with_you', HOLDER); + expect(anonymous.html).not.toContain('/unsubscribe'); + expect(anonymous.text).not.toContain('/unsubscribe'); + }); + + it('tells an invited address what to do with it', async () => { + const { html, text } = await renderShare('file_shared_invite', { + email: 'new@example.test', + subject_line: 'bob shared notes.md with you on Puter', + shares: [{ sender: 'bob', what: 'notes.md' }], + link: 'https://puter.test', + }); + + for (const part of [html, text]) { + expect(part).toContain('new@example.test'); + expect(part).toContain('Create your free account'); + // An invite has no account to unsubscribe from. + expect(part).not.toContain('/unsubscribe'); + } + expect(html).toContain('href="https://puter.test"'); + }); +}); + describe('EmailClient.clean', () => { const clean = (email: string) => startClient().clean(email); diff --git a/src/backend/clients/email/EmailClient.ts b/src/backend/clients/email/EmailClient.ts index 814df6909..c6e6a0e3e 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,7 @@ export type EmailValidator = (email: string) => Promise | boolean; interface CompiledTemplate { subject: ReturnType; html: ReturnType; + text?: ReturnType; } // -- Clean-email rules ------------------------------------------------ @@ -173,6 +178,7 @@ 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 } : {}), }); } @@ -285,7 +291,11 @@ export class EmailClient extends PuterClient { } private compileTemplates(): void { - for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) { + // Widened to the interface: the literal map keeps a distinct type per + // template, and only some of them carry a `text` part. + const templates: Record = + EMAIL_TEMPLATES; + for (const [name, template] of Object.entries(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 +306,15 @@ export class EmailClient extends PuterClient { noEscape: true, }), html: handlebars.compile(dedent(template.html)), + // Same reasoning as the subject: a text part is not HTML, so + // escaping would show entities to the reader. + ...(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..4904b68ef 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -29,8 +29,184 @@ export interface EmailTemplate { subject: string; html: string; + /** + * Optional plain-text alternative. Where a template has one it goes out + * beside the HTML as multipart/alternative, which is what plain-text + * clients, screen readers and spam scoring all prefer to a machine + * down-conversion of the markup. + */ + text?: string; } +// -- Shared layout ---------------------------------------------------- + +/** + * Colors, in one place so the two share templates can't drift apart. Every one + * of them is also written inline further down: clients that drop ` + + +
${parts.preheader}${PREHEADER_FILL}
+
+ + + + + +
+ + +`; + +/** The greeting line above the heading. */ +const greetingRow = (text: string): string => ` + + ${text} + `; + +const headingRow = (text: string): string => ` + + ${text} + `; + +/** + * One row per sender, hairline-separated. The wording comes pre-composed from + * the digest (`digestLines`), so the list stays a list however many senders and + * items fold into it. + */ +const SHARE_LIST_ROW = ` + + + + {{#each shares}} + + + + {{/each}} +
{{this.sender}} shared {{this.what}}
+ + `; + +/** A body paragraph. `padding` lets a caller tune the rhythm around it. */ +const textRow = (text: string, padding = '18px 0 0'): string => ` + + ${text} + `; + +/** + * The call to action. Padding sits on the cell and the color on `bgcolor` so + * Outlook still draws a real button (square-cornered, which is fine); the table + * goes full width under 600px so the tap target spans the card. + */ +const buttonRow = (label: string): string => ` + + + + + + + + + `; + export const EMAIL_TEMPLATES = { 'approved-for-listing': { subject: '🎉 Your app has been approved for listing!', @@ -190,85 +366,79 @@ 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: shareEmailLayout({ + preheader: 'Waiting for you under Shared with me.', + content: + greetingRow('Hi{{#if recipient}} {{recipient}}{{/if}},') + + headingRow('Shared with you') + + SHARE_LIST_ROW + + buttonRow('Open Puter') + + textRow( + 'Shared items live under Shared with me in your files. Nothing to download — the owner\'s changes show up as they make them.', + '24px 0 0', + ), + footer: `You're receiving this because someone shared with your Puter account.{{#if unsubscribe_uuid}} +
Unsubscribe from notification emails{{/if}}`, + }), + text: ` + Hi{{#if recipient}} {{recipient}}{{/if}}, + + Shared with you on Puter: + {{#each shares}} + - {{this.sender}} shared {{this.what}} + {{/each}} + + Open Puter: {{link}} + + Shared items live under "Shared with me" in your files. Nothing to + download — the owner's changes show up as they make them. + + -- + You're receiving this because someone shared with your Puter account. + {{#if unsubscribe_uuid}}Unsubscribe from notification emails: + {{link}}/unsubscribe?user_uuid={{unsubscribe_uuid}}{{/if}} `, }, // The only way to reach someone with no account. Same digest shape. 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

-
- `, - }, - share_by_username: { - subject: 'Puter share from {{susername}}', - html: ` -

Hi there {{rusername}},

-

You've received a share from {{susername}} on Puter.

-

Go to puter.com to check it out.

-{{#if message}} -

The following message was included:

-
{{message}}
-{{/if}} -

Sincerely,

-

Puter

- `, - }, - share_by_email: { - subject: 'share by email', - html: ` -

Hi there,

-

You've received a share from {{sender_name}} on Puter:

-

{{link}}

-{{#if message}} -

The following message was included:

-
{{message}}
-{{/if}} -

Sincerely,

-

Puter

+ html: shareEmailLayout({ + preheader: 'Claim it with a free Puter account.', + content: + greetingRow('Hi there,') + + headingRow('Shared with you on Puter') + + SHARE_LIST_ROW + + textRow( + 'There\'s no Puter account for {{email}} yet. Create one with this address, confirm it, and everything above will be waiting for you. It\'s free and takes about a minute.', + ) + + buttonRow('Create your free account') + + textRow( + 'Already on Puter? Add {{email}} to your account and confirm it to get the same access.', + '24px 0 0', + ), + footer: `You're receiving this because someone shared with {{email}}. Nothing is shared until the address is confirmed, so you can ignore this email and nothing happens.`, + }), + text: ` + Hi there, + + Shared with you on Puter: + {{#each shares}} + - {{this.sender}} shared {{this.what}} + {{/each}} + + There's no Puter account for {{email}} yet. Create one with this + address, confirm it, and everything above will be waiting for you. + It's free and takes about a minute. + + Create your free account: {{link}} + + Already on Puter? Add {{email}} to your account and confirm it to get + the same access. + + -- + You're receiving this because someone shared with {{email}}. Nothing is + shared until the address is confirmed, so you can ignore this email + and nothing happens. `, }, } satisfies Record; diff --git a/src/backend/services/share/shareEmail.test.ts b/src/backend/services/share/shareEmail.test.ts index 9ca1a1c71..6054959f9 100644 --- a/src/backend/services/share/shareEmail.test.ts +++ b/src/backend/services/share/shareEmail.test.ts @@ -280,7 +280,7 @@ describe('share email', () => { // The address is in the body because it is the one to sign up with: an // account on any other address will not find the share. expect(mail.html).toContain(invitee); - expect(mail.html).toContain('Create your account'); + expect(mail.html).toContain('Create your free account'); // The full origin, port included — a rebuilt protocol://domain link // is dead on any self-host that doesn't run on the default port. expect(mail.html).toContain(`href="${env.origin}"`);