Skip to content

Commit e47dc0c

Browse files
Marfuenclaude
andauthored
chore(trigger): change policy acknowledgment digest from daily to weekly (#2796)
* chore(trigger): change policy acknowledgment digest from daily to weekly Reduces email frequency for policy signature reminders from daily to weekly (Mondays at 14:00 UTC) to avoid notification fatigue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(trigger): run policy acknowledgment digest on Tuesdays Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(email): use Resend batch API for policy acknowledgment digest Adds a new `send-batch-email` Trigger.dev task that calls `resend.batch.send()` (up to 100 emails per API call) with permissive validation for partial-failure reporting. - New API endpoint `POST /v1/internal/email/send-batch` - New `sendBatchEmailViaApi` helper for app-side Trigger tasks - Digest task now renders HTML upfront, groups by org, and sends one batch request per org instead of one HTTP call per recipient - Unsubscribe headers included per-email in the batch payload Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(email): address batch email edge cases from review - Guard against missing FROM address env vars (throw early instead of sending empty string) - Fix totalSent metric: data.data only contains successes, so don't decrement for permissive-mode errors - Wrap per-recipient render() in try/catch so one bad template doesn't abort the entire digest run - Validate `to` field as email address in batch DTO Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d044c09 commit e47dc0c

6 files changed

Lines changed: 313 additions & 76 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
IsString,
3+
IsEmail,
4+
IsOptional,
5+
IsArray,
6+
ValidateNested,
7+
ArrayMinSize,
8+
} from 'class-validator';
9+
import { Type } from 'class-transformer';
10+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
11+
12+
class BatchEmailItemDto {
13+
@ApiProperty({ description: 'Recipient email address' })
14+
@IsEmail()
15+
to: string;
16+
17+
@ApiProperty({ description: 'Email subject line' })
18+
@IsString()
19+
subject: string;
20+
21+
@ApiProperty({ description: 'Pre-rendered HTML content' })
22+
@IsString()
23+
html: string;
24+
25+
@ApiPropertyOptional({ description: 'Explicit FROM address override' })
26+
@IsOptional()
27+
@IsString()
28+
from?: string;
29+
30+
@ApiPropertyOptional({ description: 'CC recipients' })
31+
@IsOptional()
32+
cc?: string | string[];
33+
}
34+
35+
export class SendBatchEmailDto {
36+
@ApiProperty({
37+
description: 'Array of emails to send',
38+
type: [BatchEmailItemDto],
39+
})
40+
@IsArray()
41+
@ArrayMinSize(1)
42+
@ValidateNested({ each: true })
43+
@Type(() => BatchEmailItemDto)
44+
emails: BatchEmailItemDto[];
45+
}

apps/api/src/email/email.controller.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
1111
import { PermissionGuard } from '../auth/permission.guard';
1212
import { RequirePermission } from '../auth/require-permission.decorator';
1313
import { SendEmailDto } from './dto/send-email.dto';
14+
import { SendBatchEmailDto } from './dto/send-batch-email.dto';
1415
import type { sendEmailTask } from '../trigger/email/send-email';
16+
import type { sendBatchEmailTask } from '../trigger/email/send-batch-email';
1517

1618
@ApiExcludeController()
1719
@ApiTags('Internal - Email')
@@ -43,4 +45,31 @@ export class EmailController {
4345

4446
return { success: true, taskId: handle.id };
4547
}
48+
49+
@Post('send-batch')
50+
@HttpCode(200)
51+
@RequirePermission('email', 'send')
52+
@ApiOperation({
53+
summary: 'Send a batch of emails via the centralized Trigger task (internal)',
54+
})
55+
@ApiResponse({ status: 200, description: 'Batch email task triggered' })
56+
async sendBatchEmail(@Body() dto: SendBatchEmailDto) {
57+
const fromAddress =
58+
process.env.RESEND_FROM_SYSTEM ?? process.env.RESEND_FROM_DEFAULT;
59+
60+
const emails = dto.emails.map((email) => ({
61+
to: email.to,
62+
subject: email.subject,
63+
html: email.html,
64+
from: email.from ?? fromAddress,
65+
cc: email.cc,
66+
}));
67+
68+
const handle = await tasks.trigger<typeof sendBatchEmailTask>(
69+
'send-batch-email',
70+
{ emails },
71+
);
72+
73+
return { success: true, taskId: handle.id };
74+
}
4675
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { logger, queue, schemaTask } from '@trigger.dev/sdk';
2+
import { z } from 'zod';
3+
import { resend } from '../../email/resend';
4+
import { generateUnsubscribeToken } from '@trycompai/email';
5+
6+
const RESEND_BATCH_LIMIT = 100;
7+
8+
const batchEmailQueue = queue({
9+
name: 'send-batch-email',
10+
concurrencyLimit: 5,
11+
});
12+
13+
const batchEmailItemSchema = z.object({
14+
to: z.string(),
15+
subject: z.string(),
16+
html: z.string(),
17+
from: z.string().optional(),
18+
cc: z.union([z.string(), z.array(z.string())]).optional(),
19+
});
20+
21+
export const sendBatchEmailTask = schemaTask({
22+
id: 'send-batch-email',
23+
queue: batchEmailQueue,
24+
retry: {
25+
maxAttempts: 3,
26+
},
27+
schema: z.object({
28+
emails: z.array(batchEmailItemSchema).min(1),
29+
}),
30+
run: async (params) => {
31+
if (!resend) {
32+
logger.error('Resend not initialized - missing RESEND_API_KEY');
33+
throw new Error('Resend not initialized - missing API key');
34+
}
35+
36+
const fromDefault =
37+
process.env.RESEND_FROM_SYSTEM ?? process.env.RESEND_FROM_DEFAULT;
38+
39+
if (!fromDefault) {
40+
throw new Error('Missing FROM address in environment variables');
41+
}
42+
43+
const toTest = process.env.RESEND_TO_TEST;
44+
const apiBaseUrl =
45+
process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai';
46+
47+
let totalSent = 0;
48+
let totalFailed = 0;
49+
50+
for (let i = 0; i < params.emails.length; i += RESEND_BATCH_LIMIT) {
51+
const chunk = params.emails.slice(i, i + RESEND_BATCH_LIMIT);
52+
53+
const payload = chunk.map((email) => {
54+
const token = generateUnsubscribeToken(email.to);
55+
const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(email.to)}&token=${encodeURIComponent(token)}`;
56+
57+
return {
58+
from: email.from ?? fromDefault,
59+
to: toTest ?? email.to,
60+
cc: email.cc,
61+
subject: email.subject,
62+
html: email.html,
63+
headers: {
64+
'List-Unsubscribe': `<${oneClickUrl}>`,
65+
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
66+
},
67+
};
68+
});
69+
70+
const { data, error } = await resend.batch.send(payload, {
71+
batchValidation: 'permissive',
72+
});
73+
74+
if (error) {
75+
logger.error('Resend batch API error', {
76+
error,
77+
chunkIndex: i,
78+
chunkSize: chunk.length,
79+
});
80+
totalFailed += chunk.length;
81+
continue;
82+
}
83+
84+
const sent = data?.data?.length ?? 0;
85+
totalSent += sent;
86+
87+
if ('errors' in data && Array.isArray(data.errors)) {
88+
for (const err of data.errors) {
89+
logger.warn('Batch email failed for recipient', {
90+
index: err.index,
91+
message: err.message,
92+
to: chunk[err.index]?.to,
93+
});
94+
totalFailed += 1;
95+
}
96+
}
97+
98+
logger.info('Batch chunk sent', {
99+
chunkIndex: i,
100+
chunkSize: chunk.length,
101+
sent,
102+
});
103+
}
104+
105+
logger.info('Batch email task complete', { totalSent, totalFailed });
106+
return { totalSent, totalFailed };
107+
},
108+
});

apps/app/src/trigger/lib/send-email-via-api.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ interface SendEmailViaApiParams {
1717
cc?: string | string[];
1818
}
1919

20+
interface BatchEmailItem {
21+
to: string;
22+
subject: string;
23+
html: string;
24+
from?: string;
25+
cc?: string | string[];
26+
}
27+
28+
interface SendBatchEmailViaApiParams {
29+
emails: BatchEmailItem[];
30+
organizationId: string;
31+
}
32+
2033
/**
2134
* Renders a React email template to HTML and sends it through the
2235
* API's centralized send-email Trigger task.
@@ -64,3 +77,42 @@ export async function sendEmailViaApi(
6477
});
6578
return { taskId: data.taskId };
6679
}
80+
81+
/**
82+
* Sends a batch of pre-rendered HTML emails through the API's
83+
* centralized send-batch-email Trigger task.
84+
*/
85+
export async function sendBatchEmailViaApi(
86+
params: SendBatchEmailViaApiParams,
87+
): Promise<{ taskId: string }> {
88+
const apiBaseUrl = getApiBaseUrl();
89+
const token = process.env.SERVICE_TOKEN_TRIGGER;
90+
91+
const response = await fetch(`${apiBaseUrl}/v1/internal/email/send-batch`, {
92+
method: 'POST',
93+
headers: {
94+
'Content-Type': 'application/json',
95+
...(token
96+
? {
97+
'x-service-token': token,
98+
'x-organization-id': params.organizationId,
99+
}
100+
: {}),
101+
},
102+
body: JSON.stringify({ emails: params.emails }),
103+
});
104+
105+
if (!response.ok) {
106+
const text = await response.text();
107+
throw new Error(
108+
`Failed to send batch email via API (${response.status}): ${text}`,
109+
);
110+
}
111+
112+
const data = (await response.json()) as { taskId: string };
113+
logger.info('Batch email triggered via API', {
114+
count: params.emails.length,
115+
taskId: data.taskId,
116+
});
117+
return { taskId: data.taskId };
118+
}

0 commit comments

Comments
 (0)