Skip to content

Commit c8bbbc4

Browse files
committed
feat: wire up Resend email sending in outreach processor
- Add resend package to worker - Create lib/email.ts (Resend client wrapper) - Create templates/outreach-email.ts (3 touch HTML+text templates) - Outreach processor now sends real emails via Resend when channel is email or mail and claimant has an email address - Track Resend message ID in outreach_records.external_id - Add RESEND_API_KEY and EMAIL_FROM env vars to docker-compose worker
1 parent f80cf94 commit c8bbbc4

6 files changed

Lines changed: 466 additions & 4 deletions

File tree

apps/worker/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"ioredis": "^5.3.0",
2222
"pdf-parse": "^2.2.2",
2323
"pg": "^8.11.0",
24-
"puppeteer": "^22.0.0"
24+
"puppeteer": "^22.0.0",
25+
"resend": "^6.4.2"
2526
},
2627
"devDependencies": {
2728
"@types/pdf-parse": "^1.1.5",

apps/worker/src/lib/email.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// ============================================================
2+
// SurplusFlow AI — Email Sender (Resend)
3+
// ============================================================
4+
5+
import { Resend } from 'resend';
6+
7+
const RESEND_API_KEY = process.env.RESEND_API_KEY || '';
8+
const EMAIL_FROM = process.env.EMAIL_FROM || 'SurplusFlow <claims@surplusflow.com>';
9+
10+
let resendClient: Resend | null = null;
11+
12+
function getClient(): Resend {
13+
if (!resendClient) {
14+
if (!RESEND_API_KEY) {
15+
throw new Error('RESEND_API_KEY is not configured');
16+
}
17+
resendClient = new Resend(RESEND_API_KEY);
18+
}
19+
return resendClient;
20+
}
21+
22+
export interface SendEmailParams {
23+
to: string;
24+
subject: string;
25+
html: string;
26+
text?: string;
27+
replyTo?: string;
28+
tags?: Array<{ name: string; value: string }>;
29+
}
30+
31+
export interface SendEmailResult {
32+
id: string;
33+
success: boolean;
34+
error?: string;
35+
}
36+
37+
export async function sendEmail(params: SendEmailParams): Promise<SendEmailResult> {
38+
const client = getClient();
39+
40+
const { data, error } = await client.emails.send({
41+
from: EMAIL_FROM,
42+
to: params.to,
43+
subject: params.subject,
44+
html: params.html,
45+
text: params.text,
46+
replyTo: params.replyTo,
47+
tags: params.tags,
48+
});
49+
50+
if (error) {
51+
return { id: '', success: false, error: error.message };
52+
}
53+
54+
return { id: data?.id ?? '', success: true };
55+
}
56+
57+
export function isEmailConfigured(): boolean {
58+
return !!RESEND_API_KEY;
59+
}

apps/worker/src/processors/outreach.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { evaluateStopRules, DEFAULT_OUTREACH_POLICY, getOutreachTemplate } from
99
import type { OutreachContext } from '@surplusflow/contracts/src/outreach.js';
1010
import { QUEUES, AUDIT_ACTIONS, COMPANY } from '@surplusflow/shared';
1111
import type { OutreachChannel } from '@surplusflow/shared';
12+
import { sendEmail, isEmailConfigured } from '../lib/email.js';
13+
import { getEmailSubject, getEmailHtml, getEmailText } from '../templates/outreach-email.js';
1214

1315
const REDIS_URL = process.env.REDIS_URL || 'redis://:sfredis_local_dev@localhost:6379';
1416
const parsed = new URL(REDIS_URL);
@@ -273,7 +275,50 @@ async function handleGenerateOutreach(
273275
`[Outreach] Created ${channel} record ${outreachRecordId} for case ${caseRow.case_number}, touch ${touchNumber}`,
274276
);
275277

276-
// 5. Schedule follow-up for next touch if not at max
278+
// 5. Send email if channel is email and claimant has an email address
279+
if ((channel === 'email' || channel === 'mail') && caseRow.email && isEmailConfigured()) {
280+
try {
281+
const subject = getEmailSubject(touchNumber, mergeData as Parameters<typeof getEmailSubject>[1]);
282+
const html = getEmailHtml(touchNumber, mergeData as Parameters<typeof getEmailHtml>[1]);
283+
const text = getEmailText(touchNumber, mergeData as Parameters<typeof getEmailText>[1]);
284+
285+
const result = await sendEmail({
286+
to: caseRow.email,
287+
subject,
288+
html,
289+
text,
290+
replyTo: COMPANY.email,
291+
tags: [
292+
{ name: 'case', value: caseRow.case_number },
293+
{ name: 'touch', value: String(touchNumber) },
294+
{ name: 'channel', value: channel },
295+
],
296+
});
297+
298+
if (result.success) {
299+
await query(
300+
`UPDATE outreach_records SET status = 'sent', sent_at = NOW(), external_id = $2 WHERE id = $1`,
301+
[outreachRecordId, result.id],
302+
);
303+
console.log(`[Outreach] Email sent to ${caseRow.email} for case ${caseRow.case_number} (resend:${result.id})`);
304+
} else {
305+
await query(
306+
`UPDATE outreach_records SET status = 'failed', stop_reason = $2 WHERE id = $1`,
307+
[outreachRecordId, `Email send failed: ${result.error}`],
308+
);
309+
console.error(`[Outreach] Email failed for case ${caseRow.case_number}: ${result.error}`);
310+
}
311+
} catch (err) {
312+
const errMsg = err instanceof Error ? err.message : String(err);
313+
await query(
314+
`UPDATE outreach_records SET status = 'failed', stop_reason = $2 WHERE id = $1`,
315+
[outreachRecordId, `Email error: ${errMsg}`],
316+
);
317+
console.error(`[Outreach] Email error for case ${caseRow.case_number}: ${errMsg}`);
318+
}
319+
}
320+
321+
// 6. Schedule follow-up for next touch if not at max
277322
if (touchNumber < DEFAULT_OUTREACH_POLICY.maxTouches) {
278323
const delayMs = getFollowupDelayMs(touchNumber);
279324
await outreachQueue.add(
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
// ============================================================
2+
// SurplusFlow AI — Outreach Email Templates
3+
// ============================================================
4+
5+
interface OutreachMerge {
6+
claimantFirstName: string;
7+
claimantLastName: string;
8+
claimantFullName: string;
9+
reportedAmount: string;
10+
propertyDescription: string | null;
11+
holderName: string | null;
12+
jurisdictionState: string;
13+
jurisdictionCounty: string | null;
14+
caseNumber: string;
15+
companyName: string;
16+
companyPhone: string;
17+
companyEmail: string;
18+
companyWebsite: string;
19+
optOutUrl: string;
20+
optOutPhone: string;
21+
feePercent: string;
22+
todayDate: string;
23+
}
24+
25+
function escapeHtml(str: string): string {
26+
return str
27+
.replace(/&/g, '&amp;')
28+
.replace(/</g, '&lt;')
29+
.replace(/>/g, '&gt;')
30+
.replace(/"/g, '&quot;');
31+
}
32+
33+
function e(val: unknown): string {
34+
return escapeHtml(String(val ?? ''));
35+
}
36+
37+
export function getEmailSubject(touchNumber: number, merge: OutreachMerge): string {
38+
switch (touchNumber) {
39+
case 1:
40+
return `${merge.claimantFirstName}, you may have unclaimed surplus funds in ${merge.jurisdictionState}`;
41+
case 2:
42+
return `Follow-up: Unclaimed funds of ${merge.reportedAmount}${merge.caseNumber}`;
43+
case 3:
44+
return `Final notice: Unclaimed surplus funds — Case ${merge.caseNumber}`;
45+
default:
46+
return `Regarding your unclaimed surplus funds — ${merge.caseNumber}`;
47+
}
48+
}
49+
50+
export function getEmailHtml(touchNumber: number, merge: OutreachMerge): string {
51+
switch (touchNumber) {
52+
case 1:
53+
return touch1Html(merge);
54+
case 2:
55+
return touch2Html(merge);
56+
case 3:
57+
return touch3Html(merge);
58+
default:
59+
return touch1Html(merge);
60+
}
61+
}
62+
63+
export function getEmailText(touchNumber: number, merge: OutreachMerge): string {
64+
switch (touchNumber) {
65+
case 1:
66+
return touch1Text(merge);
67+
case 2:
68+
return touch2Text(merge);
69+
case 3:
70+
return touch3Text(merge);
71+
default:
72+
return touch1Text(merge);
73+
}
74+
}
75+
76+
// --- Touch 1: Initial outreach ---
77+
78+
function touch1Html(m: OutreachMerge): string {
79+
return `<!DOCTYPE html>
80+
<html>
81+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
82+
<body style="font-family:Arial,sans-serif;line-height:1.6;color:#333;max-width:600px;margin:0 auto;padding:20px">
83+
<div style="border-bottom:2px solid #2563eb;padding-bottom:15px;margin-bottom:20px">
84+
<h2 style="color:#2563eb;margin:0">${e(m.companyName)}</h2>
85+
<p style="color:#666;margin:5px 0 0;font-size:14px">Surplus Recovery Services</p>
86+
</div>
87+
88+
<p>Dear ${e(m.claimantFirstName)} ${e(m.claimantLastName)},</p>
89+
90+
<p>Our records indicate that you may be entitled to unclaimed surplus funds
91+
in <strong>${e(m.jurisdictionState)}${m.jurisdictionCounty ? `, ${e(m.jurisdictionCounty)} County` : ''}</strong>.</p>
92+
93+
<div style="background:#f0f7ff;border-left:4px solid #2563eb;padding:15px;margin:20px 0;border-radius:4px">
94+
<p style="margin:0"><strong>Estimated Amount:</strong> ${e(m.reportedAmount)}</p>
95+
${m.propertyDescription ? `<p style="margin:5px 0 0"><strong>Property:</strong> ${e(m.propertyDescription)}</p>` : ''}
96+
${m.holderName ? `<p style="margin:5px 0 0"><strong>Holder:</strong> ${e(m.holderName)}</p>` : ''}
97+
<p style="margin:5px 0 0"><strong>Case Reference:</strong> ${e(m.caseNumber)}</p>
98+
</div>
99+
100+
<p>${e(m.companyName)} specializes in recovering surplus funds on behalf of rightful
101+
owners. Our service fee is <strong>${e(m.feePercent)}%</strong> of the recovered amount — you pay
102+
nothing unless we successfully recover your funds.</p>
103+
104+
<p>To learn more or begin the recovery process, please contact us:</p>
105+
106+
<div style="background:#f9fafb;padding:15px;border-radius:4px;margin:15px 0">
107+
<p style="margin:0">📞 <strong>${e(m.companyPhone)}</strong></p>
108+
<p style="margin:5px 0">✉️ <strong>${e(m.companyEmail)}</strong></p>
109+
${m.companyWebsite ? `<p style="margin:5px 0">🌐 <strong>${e(m.companyWebsite)}</strong></p>` : ''}
110+
</div>
111+
112+
<p>Sincerely,<br><strong>${e(m.companyName)}</strong></p>
113+
114+
<hr style="border:none;border-top:1px solid #e5e7eb;margin:30px 0 15px">
115+
<p style="font-size:12px;color:#999">
116+
This communication is from ${e(m.companyName)}. If you do not wish to receive
117+
further communications, please call ${e(m.optOutPhone)} or visit
118+
${e(m.optOutUrl)} to opt out. Case ref: ${e(m.caseNumber)}.
119+
</p>
120+
</body>
121+
</html>`;
122+
}
123+
124+
function touch1Text(m: OutreachMerge): string {
125+
return `Dear ${m.claimantFirstName} ${m.claimantLastName},
126+
127+
Our records indicate that you may be entitled to unclaimed surplus funds in ${m.jurisdictionState}${m.jurisdictionCounty ? `, ${m.jurisdictionCounty} County` : ''}.
128+
129+
Estimated Amount: ${m.reportedAmount}
130+
${m.propertyDescription ? `Property: ${m.propertyDescription}\n` : ''}${m.holderName ? `Holder: ${m.holderName}\n` : ''}Case Reference: ${m.caseNumber}
131+
132+
${m.companyName} specializes in recovering surplus funds on behalf of rightful owners. Our service fee is ${m.feePercent}% of the recovered amount — you pay nothing unless we successfully recover your funds.
133+
134+
To learn more or begin the recovery process, contact us:
135+
Phone: ${m.companyPhone}
136+
Email: ${m.companyEmail}
137+
${m.companyWebsite ? `Website: ${m.companyWebsite}\n` : ''}
138+
Sincerely,
139+
${m.companyName}
140+
141+
---
142+
To opt out: call ${m.optOutPhone} or visit ${m.optOutUrl}
143+
Case ref: ${m.caseNumber}`;
144+
}
145+
146+
// --- Touch 2: Follow-up ---
147+
148+
function touch2Html(m: OutreachMerge): string {
149+
return `<!DOCTYPE html>
150+
<html>
151+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
152+
<body style="font-family:Arial,sans-serif;line-height:1.6;color:#333;max-width:600px;margin:0 auto;padding:20px">
153+
<div style="border-bottom:2px solid #2563eb;padding-bottom:15px;margin-bottom:20px">
154+
<h2 style="color:#2563eb;margin:0">${e(m.companyName)}</h2>
155+
</div>
156+
157+
<p>Dear ${e(m.claimantFirstName)},</p>
158+
159+
<p>We recently contacted you regarding unclaimed surplus funds of approximately
160+
<strong>${e(m.reportedAmount)}</strong> that may belong to you in ${e(m.jurisdictionState)}.</p>
161+
162+
<p>We understand you may be busy, so we wanted to follow up. These funds
163+
have a limited recovery window, and we want to ensure you don't miss out
164+
on what may be rightfully yours.</p>
165+
166+
<div style="background:#fef3c7;border-left:4px solid #f59e0b;padding:15px;margin:20px 0;border-radius:4px">
167+
<p style="margin:0"><strong>Case Reference:</strong> ${e(m.caseNumber)}</p>
168+
<p style="margin:5px 0 0"><strong>Estimated Amount:</strong> ${e(m.reportedAmount)}</p>
169+
<p style="margin:5px 0 0">No upfront costs — our ${e(m.feePercent)}% fee is only collected upon successful recovery.</p>
170+
</div>
171+
172+
<p>Please reach out at your convenience:</p>
173+
<p>📞 ${e(m.companyPhone)} | ✉️ ${e(m.companyEmail)}</p>
174+
175+
<p>Best regards,<br><strong>${e(m.companyName)}</strong></p>
176+
177+
<hr style="border:none;border-top:1px solid #e5e7eb;margin:30px 0 15px">
178+
<p style="font-size:12px;color:#999">
179+
To opt out: ${e(m.optOutPhone)} or ${e(m.optOutUrl)}. Ref: ${e(m.caseNumber)}.
180+
</p>
181+
</body>
182+
</html>`;
183+
}
184+
185+
function touch2Text(m: OutreachMerge): string {
186+
return `Dear ${m.claimantFirstName},
187+
188+
We recently contacted you regarding unclaimed surplus funds of approximately ${m.reportedAmount} that may belong to you in ${m.jurisdictionState}.
189+
190+
These funds have a limited recovery window and we want to ensure you don't miss out.
191+
192+
Case Reference: ${m.caseNumber}
193+
Estimated Amount: ${m.reportedAmount}
194+
No upfront costs — our ${m.feePercent}% fee is only collected upon successful recovery.
195+
196+
Contact us: ${m.companyPhone} or ${m.companyEmail}
197+
198+
Best regards,
199+
${m.companyName}
200+
201+
---
202+
To opt out: ${m.optOutPhone} or ${m.optOutUrl}. Ref: ${m.caseNumber}`;
203+
}
204+
205+
// --- Touch 3: Final notice ---
206+
207+
function touch3Html(m: OutreachMerge): string {
208+
return `<!DOCTYPE html>
209+
<html>
210+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
211+
<body style="font-family:Arial,sans-serif;line-height:1.6;color:#333;max-width:600px;margin:0 auto;padding:20px">
212+
<div style="border-bottom:2px solid #2563eb;padding-bottom:15px;margin-bottom:20px">
213+
<h2 style="color:#2563eb;margin:0">${e(m.companyName)}</h2>
214+
</div>
215+
216+
<p>Dear ${e(m.claimantFirstName)},</p>
217+
218+
<p>This is our final communication regarding unclaimed surplus funds of
219+
approximately <strong>${e(m.reportedAmount)}</strong> in ${e(m.jurisdictionState)}
220+
(Case ${e(m.caseNumber)}).</p>
221+
222+
<p>If we do not hear from you, we will close this case and will not contact
223+
you again regarding this matter.</p>
224+
225+
<div style="background:#fef2f2;border-left:4px solid #ef4444;padding:15px;margin:20px 0;border-radius:4px">
226+
<p style="margin:0"><strong>This is your final notice.</strong></p>
227+
<p style="margin:5px 0 0">After this, the case will be closed and recovery may no longer be available through our services.</p>
228+
</div>
229+
230+
<p>If you'd like to proceed, contact us:<br>
231+
📞 ${e(m.companyPhone)} | ✉️ ${e(m.companyEmail)}</p>
232+
233+
<p>Respectfully,<br><strong>${e(m.companyName)}</strong></p>
234+
235+
<hr style="border:none;border-top:1px solid #e5e7eb;margin:30px 0 15px">
236+
<p style="font-size:12px;color:#999">
237+
This was our final communication. Ref: ${e(m.caseNumber)}.
238+
</p>
239+
</body>
240+
</html>`;
241+
}
242+
243+
function touch3Text(m: OutreachMerge): string {
244+
return `Dear ${m.claimantFirstName},
245+
246+
This is our final communication regarding unclaimed surplus funds of approximately ${m.reportedAmount} in ${m.jurisdictionState} (Case ${m.caseNumber}).
247+
248+
If we do not hear from you, we will close this case and will not contact you again regarding this matter.
249+
250+
If you'd like to proceed, contact us:
251+
Phone: ${m.companyPhone}
252+
Email: ${m.companyEmail}
253+
254+
Respectfully,
255+
${m.companyName}
256+
257+
---
258+
This was our final communication. Ref: ${m.caseNumber}`;
259+
}

infra/docker/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ services:
122122
S3_ACCESS_KEY: ${MINIO_ACCESS_KEY:-sfminio}
123123
S3_SECRET_KEY: ${MINIO_SECRET_KEY:-sfminio_secret_local}
124124
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-dev-encryption-key-32-characters!}
125+
RESEND_API_KEY: ${RESEND_API_KEY:-}
126+
EMAIL_FROM: ${EMAIL_FROM:-SurplusFlow <onboarding@resend.dev>}
125127
restart: unless-stopped
126128

127129
admin-web:

0 commit comments

Comments
 (0)