mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
feat(share): redesign sharing notification emails
Rebuild the two live share templates (holder digest + invite) on a proper email shell: complete HTML document, table layout, responsive below 600px, dark-mode overrides, hidden preheader, bulletproof CTA, and no images. Copy rewritten for both. Deliverability: both templates now carry a plain-text alternative part (multipart/alternative), and the holder digest sends RFC 8058 one-click unsubscribe headers backed by a new POST /unsubscribe route.
This commit is contained in:
@@ -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> | boolean;
|
||||
interface CompiledTemplate {
|
||||
subject: ReturnType<typeof template>;
|
||||
html: ReturnType<typeof template>;
|
||||
/** Present only for templates that define a plain-text part. */
|
||||
text?: ReturnType<typeof template>;
|
||||
}
|
||||
|
||||
// -- 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<T extends EmailTemplateName>(
|
||||
to: string,
|
||||
template: T,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { replyTo?: string } = {},
|
||||
options: { replyTo?: string; headers?: Record<string, string> } = {},
|
||||
): Promise<void> {
|
||||
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<EmailTemplate>(
|
||||
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,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => `
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 24px 0 0;">
|
||||
<tr>
|
||||
<td style="border-radius: 8px; background-color: #0473c9; mso-padding-alt: 13px 36px;">
|
||||
<a href="${href}" target="_blank" style="display: inline-block; padding: 13px 36px; font-family: ${FONT}; font-size: 15px; font-weight: 600; line-height: 20px; color: #ffffff; text-decoration: none; border-radius: 8px;">${label}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
/** One row per sender: "**alice** shared report.txt". */
|
||||
const shareList = `
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="list-panel" style="margin: 20px 0 0; background-color: #f6f8fb; border-radius: 10px; border-collapse: separate;">
|
||||
{{#each shares}}
|
||||
<tr>
|
||||
<td class="share-row" style="padding: 12px 16px;{{#unless @first}} border-top: 1px solid #e9edf3;{{/unless}} font-family: ${FONT}; font-size: 15px; line-height: 22px; color: #1f2937;">
|
||||
<strong style="font-weight: 600;">{{this.sender}}</strong> shared {{this.what}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>`;
|
||||
|
||||
const shareEmailShell = (opts: {
|
||||
/** Preview line clients show next to the subject; hidden in the body. */
|
||||
preheader: string;
|
||||
/** `<title>` — 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}</title>
|
||||
<!--[if mso]>
|
||||
<xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>
|
||||
<style>td, p, a, span { font-family: Arial, Helvetica, sans-serif !important; }</style>
|
||||
<![endif]-->
|
||||
<style>
|
||||
html, body { margin: 0 !important; padding: 0 !important; width: 100% !important; }
|
||||
body { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table, td { mso-table-lspace: 0pt !important; mso-table-rspace: 0pt !important; }
|
||||
img { border: 0; line-height: 100%; outline: none; text-decoration: none; }
|
||||
@media screen and (max-width: 600px) {
|
||||
.container { width: 100% !important; }
|
||||
.card { padding: 24px 20px !important; }
|
||||
.px { padding-left: 12px !important; padding-right: 12px !important; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.email-bg { background-color: #16181d !important; }
|
||||
.card { background-color: #1f2229 !important; border-color: #2e323b !important; }
|
||||
.text-main { color: #e7eaee !important; }
|
||||
.text-muted { color: #9aa3b0 !important; }
|
||||
.list-panel { background-color: #262a32 !important; }
|
||||
.share-row { border-color: #333845 !important; color: #e7eaee !important; }
|
||||
.wordmark { color: #e7eaee !important; }
|
||||
.footer-link { color: #9aa3b0 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="email-bg" style="margin: 0; padding: 0; background-color: #f0f3f7; word-break: break-word;">
|
||||
<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">${opts.preheader}${' ‌'.repeat(30)}</div>
|
||||
<div role="article" aria-roledescription="email" lang="en">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="email-bg" style="background-color: #f0f3f7;">
|
||||
<tr>
|
||||
<td align="center" class="px" style="padding: 32px 24px;">
|
||||
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0" class="container" style="width: 560px; max-width: 560px;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 0 20px;">
|
||||
<a href="{{link}}" target="_blank" class="wordmark" style="font-family: ${FONT}; font-size: 22px; font-weight: 700; letter-spacing: -0.02em; color: #1f2937; text-decoration: none;">Puter</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="card" style="background-color: #ffffff; border: 1px solid #e3e8ee; border-radius: 12px; padding: 32px;">
|
||||
${opts.content}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 12px 0;">
|
||||
${opts.footer}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export const EMAIL_TEMPLATES = {
|
||||
'approved-for-listing': {
|
||||
subject: '🎉 Your app has been approved for listing!',
|
||||
@@ -190,57 +311,76 @@ immediately</p>
|
||||
*/
|
||||
file_shared_with_you: {
|
||||
subject: '{{subject_line}}',
|
||||
html: `
|
||||
<div style="max-width: 520px; margin: 0 auto; font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color: #1f2937; line-height: 1.5;">
|
||||
<p style="font-size: 16px;">Hi {{recipient}},</p>
|
||||
<p>Shared with you on Puter:</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; border-collapse: collapse; margin: 4px 0 16px;">
|
||||
{{#each shares}}
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border-top: 1px solid #eef1f4;">
|
||||
<strong>{{this.sender}}</strong> shared {{this.what}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
<p>
|
||||
<a href="{{link}}" style="display: inline-block; padding: 10px 18px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">Open Puter</a>
|
||||
</p>
|
||||
<p style="color: #6b7280; font-size: 13px; margin-top: 24px;">Sincerely,<br />Puter</p>
|
||||
{{#if unsubscribe_uuid}}
|
||||
<p style="color: #9ca3af; font-size: 12px;">
|
||||
Don't want these? <a href="{{link}}/unsubscribe?user_uuid={{unsubscribe_uuid}}" style="color: #9ca3af;">Unsubscribe</a>.
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
html: shareEmailShell({
|
||||
title: '{{subject_line}}',
|
||||
preheader: 'It’s waiting for you in your Puter account.',
|
||||
content: `
|
||||
<p class="text-main" style="margin: 0; font-family: ${FONT}; font-size: 16px; line-height: 24px; font-weight: 600; color: #111827;">Hi {{recipient}},</p>
|
||||
<p class="text-main" style="margin: 12px 0 0; font-family: ${FONT}; font-size: 15px; line-height: 22px; color: #374151;">Here’s what was shared with you on Puter:</p>
|
||||
${shareList}
|
||||
${shareCta('Open Puter', '{{link}}')}
|
||||
<p class="text-muted" style="margin: 20px 0 0; font-family: ${FONT}; font-size: 13px; line-height: 20px; color: #6b7280;">You’ll find everything under “Shared with me” in your files.</p>`,
|
||||
footer: `
|
||||
<p class="text-muted" style="margin: 0; font-family: ${FONT}; font-size: 12px; line-height: 18px; color: #8a94a3; text-align: center;">
|
||||
You’re receiving this because someone shared items with your Puter account.
|
||||
{{#if unsubscribe_uuid}}
|
||||
<br><a href="{{link}}/unsubscribe?user_uuid={{unsubscribe_uuid}}" target="_blank" class="footer-link" style="color: #8a94a3; text-decoration: underline;">Unsubscribe</a> from Puter email notifications.
|
||||
{{/if}}
|
||||
</p>`,
|
||||
}),
|
||||
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: `
|
||||
<div style="max-width: 520px; margin: 0 auto; font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color: #1f2937; line-height: 1.5;">
|
||||
<p style="font-size: 16px;">Hi there,</p>
|
||||
<p>Shared with you on Puter:</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; border-collapse: collapse; margin: 4px 0 16px;">
|
||||
{{#each shares}}
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border-top: 1px solid #eef1f4;">
|
||||
<strong>{{this.sender}}</strong> shared {{this.what}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
<p>
|
||||
You don't have a Puter account for this address yet. Create one with
|
||||
<strong>{{email}}</strong> and confirm it, and what was shared will be
|
||||
waiting for you.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{link}}" style="display: inline-block; padding: 10px 18px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">Create your account</a>
|
||||
</p>
|
||||
<p style="color: #6b7280; font-size: 13px; margin-top: 24px;">Sincerely,<br />Puter</p>
|
||||
</div>
|
||||
html: shareEmailShell({
|
||||
title: '{{subject_line}}',
|
||||
preheader:
|
||||
'Create a free Puter account with this email address to view it.',
|
||||
content: `
|
||||
<p class="text-main" style="margin: 0; font-family: ${FONT}; font-size: 16px; line-height: 24px; font-weight: 600; color: #111827;">Hi there,</p>
|
||||
<p class="text-main" style="margin: 12px 0 0; font-family: ${FONT}; font-size: 15px; line-height: 22px; color: #374151;">Here’s what was shared with you on Puter:</p>
|
||||
${shareList}
|
||||
<p class="text-main" style="margin: 20px 0 0; font-family: ${FONT}; font-size: 15px; line-height: 22px; color: #374151;">There’s no Puter account for <strong>{{email}}</strong> yet. Create a free account with this exact address, confirm it, and everything shared with you will be waiting in your files.</p>
|
||||
${shareCta('Create your account', '{{link}}')}
|
||||
<p class="text-muted" style="margin: 20px 0 0; font-family: ${FONT}; font-size: 13px; line-height: 20px; color: #6b7280;">New to Puter? It’s the open-source Internet OS — your files, apps, and games in one place, from any device.</p>`,
|
||||
footer: `
|
||||
<p class="text-muted" style="margin: 0; font-family: ${FONT}; font-size: 12px; line-height: 18px; color: #8a94a3; text-align: center;">
|
||||
You’re receiving this because a Puter user shared something with this email address.
|
||||
<br>If you weren’t expecting it, you can safely ignore this email.
|
||||
</p>`,
|
||||
}),
|
||||
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: {
|
||||
|
||||
@@ -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('<html');
|
||||
const refreshed = await server.stores.user.getById(user.id);
|
||||
expect(Boolean(refreshed?.unsubscribed)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers 200 again for an already-unsubscribed user', async () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
}) => {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user