Skip to content

Commit 80db5d9

Browse files
claudfuenclaude
andauthored
feat: add List-Unsubscribe headers and throttle email sends (#2507)
* feat: add List-Unsubscribe headers and throttle email sends - Add List-Unsubscribe and List-Unsubscribe-Post headers to all outbound emails for Gmail/RFC 8058 one-click unsubscribe compliance - Reduce email queue concurrency from 30 to 10 - Add 1s delay between sends to avoid email spikes that trigger reputation systems Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use setTimeout instead of wait.for for email throttling wait.for suspends execution and frees the concurrency slot, defeating the throttling purpose. setTimeout holds the slot occupied for 1s, actually spacing out sends. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use real recipient email for unsubscribe URL, not test override When RESEND_TO_TEST is set, toAddress becomes the test email. The unsubscribe URL should always reference the real recipient (params.to) so the token validates correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove List-Unsubscribe-Post, add mailto fallback The one-click POST handler doesn't exist yet (unsubscribe page is GET only). Removed List-Unsubscribe-Post to avoid claiming RFC 8058 support we don't have. Added mailto fallback for broader client compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add RFC 8058 one-click unsubscribe POST endpoint - New POST /v1/email/unsubscribe endpoint that accepts email+token via query params, verifies HMAC token, and unsubscribes the user - No auth required (token IS the auth, Gmail needs to POST directly) - Re-add List-Unsubscribe-Post header now that the handler exists - List-Unsubscribe URL points to API endpoint for one-click POST Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove dead import, use timing-safe token comparison - Remove unused getUnsubscribeUrl import from send-email.ts - Use crypto.timingSafeEqual for HMAC token verification in unsubscribe endpoint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: guard against type confusion on query/body params CodeQL flagged that query params could be arrays. Explicitly coerce to string before using. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: include findingNotifications in unsubscribe preferences Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 56ba6ec commit 80db5d9

3 files changed

Lines changed: 88 additions & 2 deletions

File tree

apps/api/src/email/email.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Module } from '@nestjs/common';
22
import { AuthModule } from '../auth/auth.module';
33
import { EmailController } from './email.controller';
4+
import { UnsubscribeController } from './unsubscribe.controller';
45

56
@Module({
67
imports: [AuthModule],
7-
controllers: [EmailController],
8+
controllers: [EmailController, UnsubscribeController],
89
})
910
export class EmailModule {}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Controller, Post, Body, Query, HttpCode, BadRequestException } from '@nestjs/common';
2+
import { ApiOperation, ApiTags } from '@nestjs/swagger';
3+
import { db } from '@db';
4+
import { generateUnsubscribeToken } from '@trycompai/email/lib/unsubscribe';
5+
import { timingSafeEqual } from 'node:crypto';
6+
7+
@ApiTags('Email - Unsubscribe')
8+
@Controller({ path: 'email/unsubscribe', version: '1' })
9+
export class UnsubscribeController {
10+
/**
11+
* RFC 8058 one-click unsubscribe endpoint.
12+
* Gmail POSTs to this URL with List-Unsubscribe=One-Click in the body.
13+
* Email and token come via query params in the URL.
14+
*/
15+
@Post()
16+
@HttpCode(200)
17+
@ApiOperation({ summary: 'One-click unsubscribe (RFC 8058)' })
18+
async unsubscribe(
19+
@Query('email') queryEmail?: string,
20+
@Query('token') queryToken?: string,
21+
@Body() body?: { email?: string; token?: string },
22+
) {
23+
// Coerce to string - query params can be arrays if repeated
24+
const rawEmail = queryEmail || body?.email;
25+
const rawToken = queryToken || body?.token;
26+
const email = typeof rawEmail === 'string' ? rawEmail : undefined;
27+
const token = typeof rawToken === 'string' ? rawToken : undefined;
28+
29+
if (!email || !token) {
30+
throw new BadRequestException('Email and token are required');
31+
}
32+
33+
// Verify HMAC token (timing-safe comparison)
34+
const expectedToken = generateUnsubscribeToken(email);
35+
const tokensMatch =
36+
expectedToken.length === token.length &&
37+
timingSafeEqual(Buffer.from(expectedToken), Buffer.from(token));
38+
if (!tokensMatch) {
39+
throw new BadRequestException('Invalid token');
40+
}
41+
42+
// Unsubscribe the user from all email notifications
43+
const user = await db.user.findUnique({
44+
where: { email },
45+
select: { id: true },
46+
});
47+
48+
if (!user) {
49+
// Don't reveal user existence - just return success
50+
return { success: true };
51+
}
52+
53+
await db.user.update({
54+
where: { id: user.id },
55+
data: {
56+
emailNotificationsUnsubscribed: true,
57+
emailPreferences: {
58+
policyNotifications: false,
59+
taskReminders: false,
60+
weeklyTaskDigest: false,
61+
unassignedItemsNotifications: false,
62+
taskMentions: false,
63+
taskAssignments: false,
64+
findingNotifications: false,
65+
},
66+
},
67+
});
68+
69+
return { success: true };
70+
}
71+
}

apps/api/src/trigger/email/send-email.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { logger, queue, schemaTask } from '@trigger.dev/sdk';
22
import { z } from 'zod';
33
import { resend } from '../../email/resend';
4+
import { generateUnsubscribeToken } from '@trycompai/email/lib/unsubscribe';
45

56
const emailQueue = queue({
67
name: 'send-email',
7-
concurrencyLimit: 30,
8+
concurrencyLimit: 10,
89
});
910

1011
export const sendEmailTask = schemaTask({
@@ -51,12 +52,22 @@ export const sendEmailTask = schemaTask({
5152
}
5253

5354
try {
55+
// Build List-Unsubscribe headers for Gmail/RFC 8058 one-click compliance
56+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai';
57+
const token = generateUnsubscribeToken(params.to);
58+
const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(params.to)}&token=${encodeURIComponent(token)}`;
59+
const headers: Record<string, string> = {
60+
'List-Unsubscribe': `<${oneClickUrl}>`,
61+
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
62+
};
63+
5464
const { data, error } = await resend.emails.send({
5565
from: fromAddress,
5666
to: toAddress,
5767
cc: params.cc,
5868
subject: params.subject,
5969
html: params.html,
70+
headers,
6071
scheduledAt: params.scheduledAt,
6172
attachments: params.attachments?.map((att) => ({
6273
filename: att.filename,
@@ -76,6 +87,9 @@ export const sendEmailTask = schemaTask({
7687

7788
logger.info('Email sent', { to: params.to, id: data?.id });
7889

90+
// Throttle: hold the concurrency slot for 1s to space out sends
91+
await new Promise((r) => setTimeout(r, 1000));
92+
7993
return { id: data?.id };
8094
} catch (error) {
8195
logger.error('Email sending failed', {

0 commit comments

Comments
 (0)