From e49d42f290e772c194d27c968f90453e1c8942ba Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sun, 11 Oct 2026 03:09:47 +1100 Subject: [PATCH] fix(email): include unsubscribe headers in weekly previews --- .../server/api/admin/weekly-preview.post.ts | 7 ++- test/unit/weekly-preview-headers.test.ts | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 test/unit/weekly-preview-headers.test.ts diff --git a/layers/admin/server/api/admin/weekly-preview.post.ts b/layers/admin/server/api/admin/weekly-preview.post.ts index dce4abd7..0eb3b0bd 100644 --- a/layers/admin/server/api/admin/weekly-preview.post.ts +++ b/layers/admin/server/api/admin/weekly-preview.post.ts @@ -57,6 +57,7 @@ export default defineApiHandler({ const siteUrl = (config.publicSiteUrl as string) || 'https://skilld.dev' const unsubscribeToken = await signUnsubToken(user.id, config.tokenKey as string) + const unsubscribeUrl = `${siteUrl}/api/unsubscribe?t=${encodeURIComponent(unsubscribeToken)}&list=weekly` const rendered = renderWeekly({ recipientName: user.name, windowStart, @@ -66,7 +67,7 @@ export default defineApiHandler({ trackedCount: 0, trending, siteUrl, - unsubscribeUrl: `${siteUrl}/api/unsubscribe?t=${encodeURIComponent(unsubscribeToken)}&list=weekly`, + unsubscribeUrl, settingsUrl: `${siteUrl}/me`, }) @@ -88,6 +89,10 @@ export default defineApiHandler({ subject: `[test] ${rendered.subject}`, html: rendered.html, text: rendered.text, + headers: { + 'List-Unsubscribe': `<${unsubscribeUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, }) return { _tag: 'accepted' as const, counts, to: body.to, delivery: result } diff --git a/test/unit/weekly-preview-headers.test.ts b/test/unit/weekly-preview-headers.test.ts new file mode 100644 index 00000000..23ebc235 --- /dev/null +++ b/test/unit/weekly-preview-headers.test.ts @@ -0,0 +1,56 @@ +import type { EventHandler } from 'h3' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ session: vi.fn(), send: vi.fn(), admin: vi.fn() })) +vi.mock('../../shared/server/session-access', () => ({ readUserSession: mocks.session })) +vi.mock('../../layers/identity/server/utils/email', () => ({ + signUnsubToken: async () => 'token+/=', + sendEmailWithEnv: mocks.send, +})) +vi.mock('../../layers/identity/server/utils/weekly-select', () => ({ loadWeeklyTrending: async () => [] })) +vi.mock('#app/nuxt', async importOriginal => ({ + ...await importOriginal(), + useRuntimeConfig: () => ({ publicSiteUrl: 'https://skilld.dev', tokenKey: 'test-key', email: { from: 'noreply@mail.skilld.dev' } }), +})) + +async function request(to?: string) { + vi.stubGlobal('defineEventHandler', (handler: EventHandler) => handler) + vi.stubGlobal('readBody', async () => ({ login: 'operator', to })) + vi.stubGlobal('requireAdmin', mocks.admin) + const first = vi.fn().mockResolvedValue({ id: 7, login: 'operator', name: null, email: 'account@example.com', digest_email: null }) + const db = { prepare: () => ({ bind: () => ({ first }) }) } + const handler = (await import('../../layers/admin/server/api/admin/weekly-preview.post')).default + return handler({ method: 'POST', node: { req: { headers: {} } }, context: { platform: { db, env: {} } } } as unknown as Parameters[0]) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ user: { id: 7, login: 'operator' } }) + mocks.send.mockResolvedValue({ _tag: 'accepted', messageId: 'provider-id' }) +}) +afterEach(() => vi.unstubAllGlobals()) + +it('sends weekly previews only to the explicit address with one-click unsubscribe headers', async () => { + await request('test@example.com') + expect(mocks.send).toHaveBeenCalledWith({}, expect.objectContaining({ + to: 'test@example.com', + subject: expect.stringMatching(/^\[test\] /), + text: expect.stringContaining('https://skilld.dev/api/unsubscribe?t=token%2B%2F%3D&list=weekly'), + headers: { + 'List-Unsubscribe': '', + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + })) +}) + +it('renders without sending when no destination is supplied', async () => { + await expect(request()).resolves.toMatchObject({ _tag: 'rendered' }) + expect(mocks.send).not.toHaveBeenCalled() +}) + +it('does not send when admin authorization fails', async () => { + const denied = new Error('Forbidden') + mocks.admin.mockRejectedValueOnce(denied) + await expect(request('test@example.com')).rejects.toBe(denied) + expect(mocks.send).not.toHaveBeenCalled() +})