diff --git a/apps/api/src/automation/process-ongoing-sequence.test.ts b/apps/api/src/automation/process-ongoing-sequence.test.ts index d17d1a2..6058320 100644 --- a/apps/api/src/automation/process-ongoing-sequence.test.ts +++ b/apps/api/src/automation/process-ongoing-sequence.test.ts @@ -13,6 +13,7 @@ vi.mock("../mail/send", () => ({ import { db } from "../db/client"; import { sendMail } from "../mail/send"; +import { getUnsubLink } from "../utils/mail"; import { contacts, emailDeliveries, @@ -125,6 +126,10 @@ describe("processOngoingSequence", () => { expect(sent.to).toBe(contact.email); expect(sent.subject).toBe("First"); expect(sent.from).toBe("Test Sender "); + expect(sent.headers).toEqual({ + "List-Unsubscribe": `<${getUnsubLink(contact.unsubscribeToken)}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }); const deliveries = await tdb .select() diff --git a/apps/api/src/automation/process-ongoing-sequence.ts b/apps/api/src/automation/process-ongoing-sequence.ts index 092414e..dab36ed 100644 --- a/apps/api/src/automation/process-ongoing-sequence.ts +++ b/apps/api/src/automation/process-ongoing-sequence.ts @@ -25,6 +25,7 @@ import { getTrackedClickUrl, getTrackingPixelUrl, getUnsubLink, + getListUnsubscribeHeaders, } from "../utils/mail"; import { generatePixelToken } from "../utils/pixel-jwt"; import logger from "../services/log"; @@ -327,6 +328,7 @@ async function attemptMailSending({ espConfigId: pin.espConfigId, secretVersion: pin.secretVersion, messageId: rfcMessageId, + headers: getListUnsubscribeHeaders(contact.unsubscribeToken), }); const [delivery] = await db .insert(emailDeliveries) diff --git a/apps/api/src/tracking/routes.test.ts b/apps/api/src/tracking/routes.test.ts index aa95143..a7c3723 100644 --- a/apps/api/src/tracking/routes.test.ts +++ b/apps/api/src/tracking/routes.test.ts @@ -55,13 +55,27 @@ type TestResponse = { }; async function get(path: string): Promise { + return request("GET", path); +} + +async function request( + method: string, + path: string, + body?: string, + contentType?: string, +): Promise { const app = express(); app.use(trackingRoutes); const req = new IncomingMessage(new Socket()); - req.method = "GET"; + req.method = method; req.url = path; req.headers = { host: "localhost:5000" }; + if (body !== undefined) { + req.headers["content-type"] = + contentType ?? "application/x-www-form-urlencoded"; + req.headers["content-length"] = String(Buffer.byteLength(body)); + } const res = new ServerResponse(req); const chunks: Buffer[] = []; @@ -84,6 +98,7 @@ async function get(path: string): Promise { }); (app as any).handle(req, res); + if (body !== undefined) req.push(body); req.push(null); return done; } @@ -229,4 +244,27 @@ describe("tracking and unsubscribe routes", () => { const unknown = await get("/unsubscribe/unknown"); expect(unknown.status).toBe(404); }); + + it("unsubscribes on an RFC 8058 one-click POST without a confirmation page", async () => { + mocks.getContactByUnsubscribeToken.mockResolvedValue({ + teamId: "team-internal", + contactId: "cnt_public", + email: "reader@example.com", + subscribed: true, + }); + + const response = await request( + "POST", + "/unsubscribe/unsub-token", + "List-Unsubscribe=One-Click", + ); + + expect(response.status).toBe(200); + expect(response.body.toString()).not.toContain(" { + return { + "List-Unsubscribe": `<${getUnsubLink(unsubscribeToken)}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }; +} + export function getTrackingPixelUrl(token: string): string { return `${getSiteUrl()}/track/open?d=${token}`; }