Skip to content

Commit 2730b8a

Browse files
committed
feat(one-click): implement RFC 8058 one-click unsubscribe
1 parent 453442b commit 2730b8a

File tree

6 files changed

+336
-13
lines changed

6 files changed

+336
-13
lines changed

src/app/[locale]/(redesign)/(authenticated)/admin/emails/actions.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ async function send(
4848
emailAddress: string,
4949
subject: string,
5050
template: ReactNode,
51-
plaintextVersion?: string,
51+
options?: { plaintextVersion?: string; oneClickUrl?: string },
5252
) {
5353
const subscriber = await getAdminSubscriber();
5454
if (!subscriber) {
@@ -68,7 +68,12 @@ async function send(
6868
emailAddress,
6969
"Test email: " + subject,
7070
await renderEmail(template),
71-
plaintextVersion,
71+
{
72+
plaintext: options?.plaintextVersion,
73+
...(options?.oneClickUrl && {
74+
oneClickUnsubscribe: { url: options?.oneClickUrl },
75+
}),
76+
},
7277
);
7378
}
7479

@@ -127,7 +132,8 @@ export async function triggerBreachAlert(emailAddress: string) {
127132

128133
const acceptLangHeader = await getAcceptLangHeaderInServerComponents();
129134
const l10n = getL10n(acceptLangHeader);
130-
const unsubscribeLink = await getBreachAlertsUnsubscribeLink(subscriber);
135+
const { footer: unsubscribeLink, oneClick: oneClickUrl } =
136+
await getBreachAlertsUnsubscribeLink(subscriber);
131137

132138
await send(
133139
emailAddress,
@@ -140,5 +146,6 @@ export async function triggerBreachAlert(emailAddress: string) {
140146
l10n={l10n}
141147
unsubscribeLink={unsubscribeLink}
142148
/>,
149+
{ oneClickUrl },
143150
);
144151
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
// @vitest-environment node
6+
7+
import { describe, it, expect, vi, afterEach } from "vitest";
8+
import type { NextRequest } from "next/server";
9+
10+
vi.mock("../../../../../db/tables/email_subscriptions", () => ({
11+
getEmailSubscriptionByToken: vi.fn(),
12+
unsubscribeEmailSubscription: vi.fn(),
13+
}));
14+
15+
vi.mock("../../../../functions/server/logging", () => ({
16+
logger: { info: vi.fn(), error: vi.fn() },
17+
}));
18+
19+
vi.mock("@sentry/nextjs", () => ({
20+
captureException: vi.fn(),
21+
}));
22+
23+
import {
24+
getEmailSubscriptionByToken,
25+
unsubscribeEmailSubscription,
26+
} from "../../../../../db/tables/email_subscriptions";
27+
import { EmailSubscriptionsRow } from "knex/types/tables";
28+
import { BREACH_ALERT_LIST_ID } from "../../../../../constants";
29+
30+
afterEach(() => {
31+
vi.clearAllMocks();
32+
});
33+
34+
const token = "valid-token-abc";
35+
const mockSubscription: EmailSubscriptionsRow = {
36+
id: "1",
37+
subscriber_id: 1,
38+
token,
39+
list_id: BREACH_ALERT_LIST_ID,
40+
subscribed: true,
41+
updated_at: new Date(),
42+
};
43+
44+
function makeReq(
45+
url: string,
46+
{
47+
contentType,
48+
formFields,
49+
}: { contentType?: string; formFields?: Record<string, string> } = {},
50+
): NextRequest {
51+
const fd = new FormData();
52+
for (const [key, value] of Object.entries(formFields ?? {})) {
53+
fd.append(key, value);
54+
}
55+
return {
56+
url,
57+
headers: {
58+
get: (name: string) =>
59+
name === "content-type" ? (contentType ?? null) : null,
60+
},
61+
formData: () => Promise.resolve(fd),
62+
} as unknown as NextRequest;
63+
}
64+
65+
describe("POST /api/v1/user/one-click-unsubscribe", () => {
66+
it("returns 400 when no token is provided", async () => {
67+
const { POST } = await import("./route");
68+
69+
const req = makeReq(
70+
"https://example.com/api/v1/user/one-click-unsubscribe",
71+
);
72+
73+
const res = await POST(req);
74+
expect(res.status).toBe(400);
75+
const body = await res.json();
76+
expect(body.success).toBe(false);
77+
expect(body.message).toMatch(/token/i);
78+
});
79+
80+
it("returns 400 when content-type is form-urlencoded but List-Unsubscribe field is missing", async () => {
81+
const { POST } = await import("./route");
82+
83+
const req = makeReq(
84+
`https://example.com/api/v1/user/one-click-unsubscribe?token=${token}`,
85+
{
86+
contentType: "application/x-www-form-urlencoded",
87+
formFields: { some: "other" },
88+
},
89+
);
90+
91+
const res = await POST(req);
92+
expect(res.status).toBe(400);
93+
const body = await res.json();
94+
expect(body.success).toBe(false);
95+
expect(body.message).toMatch(/List-Unsubscribe=One-Click/);
96+
});
97+
98+
it("returns 200 when content-type is form-urlencoded and List-Unsubscribe=One-Click", async () => {
99+
vi.mocked(getEmailSubscriptionByToken).mockResolvedValue(mockSubscription);
100+
vi.mocked(unsubscribeEmailSubscription).mockResolvedValue(undefined);
101+
102+
const { POST } = await import("./route");
103+
104+
const req = makeReq(
105+
`https://example.com/api/v1/user/one-click-unsubscribe?token=${token}`,
106+
{
107+
contentType: "application/x-www-form-urlencoded",
108+
formFields: { "List-Unsubscribe": "One-Click" },
109+
},
110+
);
111+
112+
const res = await POST(req);
113+
expect(res.status).toBe(200);
114+
const body = await res.json();
115+
expect(body.success).toBe(true);
116+
expect(unsubscribeEmailSubscription).toHaveBeenCalledWith(
117+
mockSubscription,
118+
"one-click",
119+
);
120+
});
121+
122+
it("returns 400 when content-type is multipart/form-data but List-Unsubscribe field is missing", async () => {
123+
const { POST } = await import("./route");
124+
125+
const req = makeReq(
126+
`https://example.com/api/v1/user/one-click-unsubscribe?token=${token}`,
127+
{
128+
contentType: "multipart/form-data; boundary=----boundary",
129+
formFields: { some: "other" },
130+
},
131+
);
132+
133+
const res = await POST(req);
134+
expect(res.status).toBe(400);
135+
const body = await res.json();
136+
expect(body.success).toBe(false);
137+
expect(body.message).toMatch(/List-Unsubscribe=One-Click/);
138+
});
139+
140+
it("returns 200 when content-type is multipart/form-data and List-Unsubscribe=One-Click", async () => {
141+
vi.mocked(getEmailSubscriptionByToken).mockResolvedValue(mockSubscription);
142+
vi.mocked(unsubscribeEmailSubscription).mockResolvedValue(undefined);
143+
144+
const { POST } = await import("./route");
145+
146+
const req = makeReq(
147+
`https://example.com/api/v1/user/one-click-unsubscribe?token=${token}`,
148+
{
149+
contentType: "multipart/form-data; boundary=----boundary",
150+
formFields: { "List-Unsubscribe": "One-Click" },
151+
},
152+
);
153+
154+
const res = await POST(req);
155+
expect(res.status).toBe(200);
156+
const body = await res.json();
157+
expect(body.success).toBe(true);
158+
expect(unsubscribeEmailSubscription).toHaveBeenCalledWith(
159+
mockSubscription,
160+
"one-click",
161+
);
162+
});
163+
164+
it("returns 200 if token does not map to a subscription", async () => {
165+
vi.mocked(getEmailSubscriptionByToken).mockResolvedValue(undefined);
166+
167+
const { POST } = await import("./route");
168+
169+
const req = makeReq(
170+
`https://example.com/api/v1/user/one-click-unsubscribe?token=${token}`,
171+
);
172+
173+
const res = await POST(req);
174+
expect(res.status).toBe(200);
175+
const body = await res.json();
176+
expect(body.success).toBe(true);
177+
expect(getEmailSubscriptionByToken).toHaveBeenCalledWith(token);
178+
expect(unsubscribeEmailSubscription).not.toHaveBeenCalled();
179+
});
180+
181+
it("returns 200 and unsubscribes with source 'one-click' on success", async () => {
182+
vi.mocked(getEmailSubscriptionByToken).mockResolvedValue(mockSubscription);
183+
vi.mocked(unsubscribeEmailSubscription).mockResolvedValue(undefined);
184+
185+
const { POST } = await import("./route");
186+
187+
const req = makeReq(
188+
`https://example.com/api/v1/user/one-click-unsubscribe?token=${token}`,
189+
);
190+
191+
const res = await POST(req);
192+
expect(res.status).toBe(200);
193+
const body = await res.json();
194+
expect(body.success).toBe(true);
195+
expect(unsubscribeEmailSubscription).toHaveBeenCalledWith(
196+
mockSubscription,
197+
"one-click",
198+
);
199+
});
200+
201+
it("returns 500 when unsubscribeEmailSubscription throws", async () => {
202+
vi.mocked(getEmailSubscriptionByToken).mockResolvedValue(mockSubscription);
203+
vi.mocked(unsubscribeEmailSubscription).mockRejectedValue(
204+
new Error("db failure"),
205+
);
206+
207+
const { POST } = await import("./route");
208+
209+
const req = makeReq(
210+
`https://example.com/api/v1/user/one-click-unsubscribe?token=${token}`,
211+
);
212+
213+
const res = await POST(req);
214+
expect(res.status).toBe(500);
215+
const body = await res.json();
216+
expect(body.success).toBe(false);
217+
});
218+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { NextResponse } from "next/server";
6+
import type { NextRequest } from "next/server";
7+
import { logger } from "../../../../functions/server/logging";
8+
import {
9+
getEmailSubscriptionByToken,
10+
unsubscribeEmailSubscription,
11+
} from "../../../../../db/tables/email_subscriptions";
12+
import * as Sentry from "@sentry/nextjs";
13+
14+
/**
15+
* One-click unsubscribe endpoint per RFC 8058.
16+
* https://datatracker.ietf.org/doc/html/rfc8058
17+
*
18+
* Email clients that support one-click unsubscribe will POST to this
19+
* endpoint with a body of `List-Unsubscribe=One-Click` and expect a
20+
* 200 response with no redirect.
21+
*/
22+
export async function POST(req: NextRequest) {
23+
try {
24+
const { searchParams } = new URL(req.url);
25+
const unsubToken = searchParams.get("token");
26+
if (!unsubToken) {
27+
return NextResponse.json(
28+
{
29+
success: false,
30+
message: "token is a required url parameter.",
31+
},
32+
{ status: 400 },
33+
);
34+
}
35+
36+
// RFC 8058 §3: the POST body MUST contain a field named "List-Unsubscribe"
37+
// with value "One-Click". The content type SHOULD be multipart/form-data
38+
// or MAY be application/x-www-form-urlencoded.
39+
const contentType = req.headers.get("content-type") ?? "";
40+
if (
41+
contentType.includes("multipart/form-data") ||
42+
contentType.includes("application/x-www-form-urlencoded")
43+
) {
44+
const formData = await req.formData();
45+
if (formData.get("List-Unsubscribe") !== "One-Click") {
46+
return NextResponse.json(
47+
{
48+
success: false,
49+
message: "Body must contain List-Unsubscribe=One-Click.",
50+
},
51+
{ status: 400 },
52+
);
53+
}
54+
}
55+
56+
const subscriptionRecord = await getEmailSubscriptionByToken(unsubToken);
57+
if (!subscriptionRecord) {
58+
logger.info("No email_subscription associated with token", {
59+
token: unsubToken,
60+
});
61+
// Return 200 per RFC 8058 to avoid leaking token validity
62+
return NextResponse.json({ success: true }, { status: 200 });
63+
}
64+
await unsubscribeEmailSubscription(subscriptionRecord, "one-click");
65+
return NextResponse.json({ success: true }, { status: 200 });
66+
} catch (e) {
67+
logger.error("one_click_unsubscribe_email", { error: e });
68+
Sentry.captureException(e);
69+
return NextResponse.json({ success: false }, { status: 500 });
70+
}
71+
}

src/app/functions/cronjobs/unsubscribeLinks.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,20 @@ import { BREACH_ALERT_LIST_ID } from "../../../constants";
1010
import { config } from "../../../config";
1111

1212
/**
13-
* Create an unsubscribe link for use in the breach alerts
14-
* email footer
13+
* Create unsubscribe links for use in the breach alerts
14+
* email footer, and one-click unsubscribe URL (RFC 8058).
1515
*/
1616
export async function getBreachAlertsUnsubscribeLink(
1717
subscriber: Pick<SubscriberRow, "id">,
18-
) {
18+
): Promise<{ footer: string; oneClick: string }> {
1919
try {
2020
const token = await getOrCreateUnsubscribeToken(
2121
subscriber.id,
2222
BREACH_ALERT_LIST_ID,
2323
);
24-
return `${config.serverUrl}/unsubscribe/breach-alerts?token=${token}`;
24+
const footer = `${config.serverUrl}/unsubscribe/breach-alerts?token=${token}`;
25+
const oneClick = `${config.serverUrl}/api/v1/user/one-click-unsubscribe?token=${token}`;
26+
return { footer, oneClick };
2527
} catch (e) {
2628
logger.error("generate_unsubscribe_link", {
2729
exception: e,

src/scripts/cronjobs/emailBreachAlerts/emailBreachAlerts.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,8 @@ export async function breachMessageHandler(
201201

202202
const l10n = getCronjobL10n(recipient);
203203
const subject = l10n.getString("email-breach-alert-all-subject");
204-
const unsubscribeLink = await getBreachAlertsUnsubscribeLink({
205-
id: recipient.subscriber_id,
206-
});
204+
const { footer: unsubscribeLink, oneClick: oneClickUnsubscribeUrl } =
205+
await getBreachAlertsUnsubscribeLink({ id: recipient.subscriber_id });
207206
await sendEmail(
208207
recipient.notification_email,
209208
subject,
@@ -217,6 +216,7 @@ export async function breachMessageHandler(
217216
unsubscribeLink={unsubscribeLink}
218217
/>,
219218
),
219+
{ oneClickUnsubscribe: { url: oneClickUnsubscribeUrl } },
220220
);
221221
await notifications.markEmailAsNotified(
222222
recipient.subscriber_id,

0 commit comments

Comments
 (0)