Skip to content

Commit e0fafcc

Browse files
bokelleyclaude
andauthored
Parse forwarded email bodies to extract original recipients (#549)
When users forward emails to addie+prospect@, the actual prospect recipients are in the email body as quoted headers, not in the webhook TO/CC fields. This adds a parser to extract them. - Add forwarded-email-parser.ts utility with detection for Gmail, Apple Mail, and Outlook forwarding formats - Integrate into handleProspectEmail() to merge extracted recipients - Add 39 unit tests covering parsing, edge cases, and security Security improvements based on code review: - Added MAX_BODY_SIZE (1MB) limit to prevent DoS - Rewrote parseHeaderValue to use iterative approach (avoids ReDoS) - Fixed unbalanced bracket handling in splitAddresses - Added tests for XSS-like display names and large recipient lists 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 0ddfbf5 commit e0fafcc

File tree

4 files changed

+963
-2
lines changed

4 files changed

+963
-2
lines changed

.changeset/green-bottles-fly.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

server/src/routes/webhooks.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ import {
2929
handleEmailInvocation,
3030
type InboundEmailContext,
3131
} from '../addie/email-handler.js';
32+
import {
33+
parseForwardedEmailHeaders,
34+
formatEmailAddress,
35+
mergeAddresses,
36+
} from '../utils/forwarded-email-parser.js';
3237

3338
const logger = createLogger('webhooks');
3439

@@ -549,15 +554,41 @@ async function handleProspectEmail(data: ResendInboundPayload['data']): Promise<
549554
const emailBody = await fetchEmailBody(data.email_id);
550555

551556
// Use original recipients from headers if available, otherwise fall back to webhook data
552-
const toAddresses = emailBody?.originalTo?.length ? emailBody.originalTo : data.to;
553-
const ccAddresses = emailBody?.originalCc?.length ? emailBody.originalCc : data.cc;
557+
let toAddresses = emailBody?.originalTo?.length ? emailBody.originalTo : data.to;
558+
let ccAddresses = emailBody?.originalCc?.length ? emailBody.originalCc : data.cc;
559+
560+
// Check if this is a forwarded email and extract original recipients from the body
561+
const forwardedInfo = parseForwardedEmailHeaders(data.subject, emailBody?.text);
562+
563+
if (forwardedInfo.isForwarded && forwardedInfo.confidence !== 'low') {
564+
// Extract additional recipients from the forwarded email headers in the body
565+
const forwardedTo = forwardedInfo.originalTo.map(formatEmailAddress);
566+
const forwardedCc = forwardedInfo.originalCc.map(formatEmailAddress);
567+
568+
// Merge with existing addresses, avoiding duplicates
569+
toAddresses = mergeAddresses(toAddresses, forwardedTo);
570+
ccAddresses = mergeAddresses(ccAddresses || [], forwardedCc);
571+
572+
logger.info({
573+
isForwarded: true,
574+
confidence: forwardedInfo.confidence,
575+
forwardedToCount: forwardedInfo.originalTo.length,
576+
forwardedCcCount: forwardedInfo.originalCc.length,
577+
forwardedTo: forwardedInfo.originalTo.map(a => a.email),
578+
forwardedCc: forwardedInfo.originalCc.map(a => a.email),
579+
originalSubject: forwardedInfo.originalSubject,
580+
}, 'Extracted recipients from forwarded email body');
581+
}
554582

555583
logger.info({
556584
webhookTo: data.to,
557585
webhookCc: data.cc,
558586
originalTo: emailBody?.originalTo,
559587
originalCc: emailBody?.originalCc,
588+
finalTo: toAddresses,
589+
finalCc: ccAddresses,
560590
usingOriginalRecipients: !!(emailBody?.originalTo?.length || emailBody?.originalCc?.length),
591+
isForwarded: forwardedInfo.isForwarded,
561592
}, 'Resolving email recipients');
562593

563594
// Get all external participants using original recipients

0 commit comments

Comments
 (0)