Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/src/automation/process-ongoing-sequence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -125,6 +126,10 @@ describe("processOngoingSequence", () => {
expect(sent.to).toBe(contact.email);
expect(sent.subject).toBe("First");
expect(sent.from).toBe("Test Sender <sender@example.com>");
expect(sent.headers).toEqual({
"List-Unsubscribe": `<${getUnsubLink(contact.unsubscribeToken)}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
});

const deliveries = await tdb
.select()
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/automation/process-ongoing-sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getTrackedClickUrl,
getTrackingPixelUrl,
getUnsubLink,
getListUnsubscribeHeaders,
} from "../utils/mail";
import { generatePixelToken } from "../utils/pixel-jwt";
import logger from "../services/log";
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 39 additions & 1 deletion apps/api/src/tracking/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,27 @@ type TestResponse = {
};

async function get(path: string): Promise<TestResponse> {
return request("GET", path);
}

async function request(
method: string,
path: string,
body?: string,
contentType?: string,
): Promise<TestResponse> {
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[] = [];
Expand All @@ -84,6 +98,7 @@ async function get(path: string): Promise<TestResponse> {
});

(app as any).handle(req, res);
if (body !== undefined) req.push(body);
req.push(null);
return done;
}
Expand Down Expand Up @@ -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("<form");
expect(mocks.updateContact).toHaveBeenCalledWith(
"team-internal",
"cnt_public",
{ subscribed: false },
);
});
});
16 changes: 16 additions & 0 deletions apps/api/src/utils/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ export function getUnsubLink(unsubscribeToken: string): string {
return `${getSiteUrl()}/unsubscribe/${unsubscribeToken}`;
}

/**
* RFC 2369 / RFC 8058 headers for marketing (broadcast and sequence) mail.
* Mailbox providers POST `List-Unsubscribe=One-Click` to the https URL, which
* `tracking/routes.ts` handles without a confirmation page. The URL is the
* contact's existing per-contact unsubscribe link. No mailto is included
* because there is no configured unsubscribe mailbox to receive it.
*/
export function getListUnsubscribeHeaders(
unsubscribeToken: string,
): Record<string, string> {
return {
"List-Unsubscribe": `<${getUnsubLink(unsubscribeToken)}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
};
}

export function getTrackingPixelUrl(token: string): string {
return `${getSiteUrl()}/track/open?d=${token}`;
}
Expand Down
Loading