Cross-Referencing Phone Calls with Email Threads for Automated Follow-Ups in C#
Series Overview:
Part 1: Using the Claude API to Generate Automated Email Responses — covers the foundation: reading emails, sending them to Claude for analysis, and generating professional reply drafts.
Part 2: Automating Call Scheduling with Claude API and Calendly — adds scheduling intelligence: detecting availability requests, fetching real Calendly timeslots, and booking meetings through email conversations.
Part 3 (this post): Cross-Referencing Phone Calls with Email Threads — bridges the gap between phone and email channels by syncing VoIP call logs, matching callers to email threads by phone number, and generating contextual follow-up emails — with Calendly availability included when appropriate.
The Problem
You’re managing email conversations (as we built in Parts 1 and 2), but people don’t only communicate by email. A recruiter sends an email about a position, then calls your phone the next day. If you miss that call, there’s no automatic connection between the phone call and the email thread. You have to manually notice the missed call, look up who it was, find the related email, and draft a follow-up.
This post automates that entire process by:
1. Syncing call logs, voicemails, and SMS messages from a VoIP provider API
2. Cross-referencing caller phone numbers against phone numbers stored in email records
3. Generating contextual follow-up emails that continue the existing email thread
4. Automatically including Calendly availability when the interaction suggests a scheduling need (using the Calendly integration from Part 2)
Architecture Overview
The Cross-Reference Flow:
1. Sync phone logs (calls, SMS, voicemails) from VoIP API into database
2. For each unprocessed incoming phone log:
a. Normalize the caller’s phone number
b. Search the Emails table for threads where that phone number appears
c. If a match is found, generate a follow-up email continuing that thread
d. If the interaction implies a scheduling need, attach Calendly availability
3. Send or queue the follow-up email, then mark the phone log as processed
Prerequisites
This post builds on the email reply system (Part 1) and Calendly integration (Part 2). You’ll additionally need:
- A VoIP provider with an API (e.g., OpenPhone, Twilio, RingCentral)
- A database to store phone logs alongside your email records
- The MailKit NuGet package for sending follow-up emails
The Database Schema
The key to cross-referencing is storing phone numbers when emails arrive. In Part 1, we saved emails to the database. Now we extend that to also extract and store phone numbers from email signatures:
— Emails table (extended from Part 1 with phone fields)
CREATE TABLE Emails (
Id INT IDENTITY(1,1) PRIMARY KEY,
MessageId NVARCHAR(500) NOT NULL,
[From] NVARCHAR(500),
FirstName NVARCHAR(200),
LastName NVARCHAR(200),
EmailFrom NVARCHAR(500),
SendersPhoneOffice NVARCHAR(50), — extracted from signature
SendersPhoneMobile NVARCHAR(50), — extracted from signature
SendersCompanyName NVARCHAR(500),
[Date] DATETIME2,
Subject NVARCHAR(1000),
Body NVARCHAR(MAX),
ReceivedAt DATETIME2 DEFAULT GETDATE()
);
— Phone logs synced from VoIP provider
CREATE TABLE PhoneLogs (
Id INT IDENTITY(1,1) PRIMARY KEY,
ExternalId NVARCHAR(100) NOT NULL,
Type NVARCHAR(20) NOT NULL, — ‘call’, ‘missed_call’, ‘voicemail’, ‘sms’
Direction NVARCHAR(20), — ‘incoming’, ‘outgoing’
Status NVARCHAR(50),
PhoneNumberId NVARCHAR(100),
FromNumber NVARCHAR(50),
ToNumber NVARCHAR(50),
Duration INT NULL,
Body NVARCHAR(MAX) NULL, — SMS text or voicemail transcript
RecordingUrl NVARCHAR(1000) NULL,
Processed BIT DEFAULT 0,
CreatedAt DATETIME2,
ReceivedAt DATETIME2 DEFAULT GETDATE(),
CONSTRAINT UQ_PhoneLogs_ExternalId UNIQUE(ExternalId)
);
Step 1: Extract Phone Numbers from Emails
When downloading emails (Part 1), we parse phone numbers from the email body and signature using regex patterns. This gives us the data we’ll later use for cross-referencing:
using System.Text.RegularExpressions;
// Pattern for labeled phone numbers in signatures
static readonly Regex OfficePhoneRegex = new(
@”(?:office|direct|desk|tel|phone|work|main)[\s:.-]*(” +
@”(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})”,
RegexOptions.Compiled | RegexOptions.IgnoreCase);
static readonly Regex MobilePhoneRegex = new(
@”(?:mobile|mob|cell|cellular)[\s:.-]*(” +
@”(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})”,
RegexOptions.Compiled | RegexOptions.IgnoreCase);
// Generic phone pattern for unlabeled numbers in signature area
static readonly Regex PhoneRegex = new(
@”(?:(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})”,
RegexOptions.Compiled);
static (string? PhoneOffice, string? PhoneMobile) ParsePhones(string body)
{
string? phoneOffice = null, phoneMobile = null;
var officeMatch = OfficePhoneRegex.Match(body);
if (officeMatch.Success)
phoneOffice = officeMatch.Groups[1].Value.Trim();
var mobileMatch = MobilePhoneRegex.Match(body);
if (mobileMatch.Success)
phoneMobile = mobileMatch.Groups[1].Value.Trim();
// Fallback: check the signature area (last ~30 lines)
if (phoneOffice == null && phoneMobile == null)
{
var lines = body.Split(‘\n’);
var signatureArea = string.Join(“\n”,
lines.Skip(Math.Max(0, lines.Length – 30)));
var phoneMatch = PhoneRegex.Match(signatureArea);
if (phoneMatch.Success)
phoneOffice = phoneMatch.Value.Trim();
}
return (phoneOffice, phoneMobile);
}
Step 2: Sync Phone Logs from VoIP Provider
The VoIP sync pulls calls, SMS, and voicemails into the PhoneLogs table. Most VoIP APIs require you to specify a participant when querying calls. The approach: first discover all conversation participants, then query calls per participant:
public async Task SyncPhoneLogs(HttpClient http, string phoneNumberId)
{
// Step 1: Discover participants from recent conversations (last 14 days)
var participants = await GetConversationParticipants(http, phoneNumberId);
// Step 2: For each participant, fetch their calls
foreach (var participant in participants)
{
string url = $”https://api.voipprovider.com/v1/calls”
+ $”?phoneNumberId={Uri.EscapeDataString(phoneNumberId)}”
+ $”&participants[]={Uri.EscapeDataString(participant)}”
+ $”&maxResults=100″;
var resp = await http.GetStringAsync(url);
var doc = JsonDocument.Parse(resp);
foreach (var call in doc.RootElement.GetProperty(“data”).EnumerateArray())
{
string callId = call.GetProperty(“id”).GetString()!;
// Skip if already synced (idempotent)
if (await phoneLogRepo.ExistsAsync(callId))
continue;
string? direction = GetOptionalString(call, “direction”);
string? status = GetOptionalString(call, “status”);
DateTime createdAt = call.GetProperty(“createdAt”).GetDateTime();
// Classify the call type
string type = (status == “missed” || status == “no-answer”)
? “missed_call” : “call”;
string? fromNumber = direction == “incoming” ? participant : null;
string? toNumber = direction == “outgoing” ? participant : null;
await phoneLogRepo.InsertAsync(callId, type, direction, status,
phoneNumberId, fromNumber, toNumber, createdAt);
}
}
}
Step 3: Cross-Reference Phone to Email
This is the core of the system. For each unprocessed incoming phone log, we normalize the caller’s number and search for matching email threads:
public async Task ProcessPhoneFollowUps()
{
var unprocessed = await phoneLogRepo.GetUnprocessedIncomingAsync();
foreach (var log in unprocessed)
{
if (string.IsNullOrEmpty(log.FromNumber))
{
await phoneLogRepo.MarkProcessedAsync(log.Id);
continue;
}
// Normalize to last 10 digits for matching
string normalized = NormalizePhone(log.FromNumber);
// Search emails where this phone number appears
var matchingEmails = await emailRepo.FindByPhoneNumberAsync(normalized);
if (matchingEmails.Count == 0)
{
// No email thread for this caller — skip
await phoneLogRepo.MarkProcessedAsync(log.Id);
continue;
}
// Use the most recent email as the thread to continue
var latestEmail = matchingEmails[0]; // ordered by date DESC
// … generate follow-up email (Step 4)
}
}
static string NormalizePhone(string phone)
{
// Strip everything except digits, keep last 10
string digits = Regex.Replace(phone, @”\D”, “”);
return digits.Length > 10 ? digits[^10..] : digits;
}
The SQL query that powers FindByPhoneNumberAsync searches across multiple fields:
SELECT e.Id, e.[From], e.Subject, e.Body, e.[Date],
e.FirstName, e.LastName, e.EmailFrom
FROM Emails e
WHERE (e.SendersPhoneOffice LIKE ‘%’ + @Phone + ‘%’
OR e.SendersPhoneMobile LIKE ‘%’ + @Phone + ‘%’
OR e.Body LIKE ‘%’ + @Phone + ‘%’)
ORDER BY e.[Date] DESC
Why search the body too? Sometimes a phone number appears in the email body (e.g., “call me at 555-123-4567”) but wasn’t parsed into the dedicated phone fields. Searching the body as a fallback catches these cases. The normalized 10-digit format ensures matches work regardless of formatting differences (parentheses, dashes, spaces).
Step 4: Generate Context-Aware Follow-Up Emails
The follow-up email must reference the existing email thread naturally. We build different responses based on whether the phone interaction was a missed call, voicemail, or SMS:
static string BuildFollowUpBody(string type, string firstName,
string emailSubject, string? phoneBody)
{
string greeting = !string.IsNullOrEmpty(firstName)
? $”Hi {firstName},” : “Hi,”;
string topicRef = $”regarding \”{emailSubject.Replace(“RE: “, “”).Trim()}\””;
return type switch
{
“missed_call” =>
greeting + “\n\n”
+ $”I noticed that you called me {topicRef}. “
+ “I’m sorry I missed your call.\n\n”
+ “Could you please let me know what the call was about, “
+ “or if you’d like to schedule a time to connect? “
+ “I’m happy to jump on a call at your convenience.\n\n”
+ “Best regards,\nJohn”,
“voicemail” =>
greeting + “\n\n”
+ $”I noticed that you called me {topicRef}. “
+ “I’m sorry I missed your call.\n\n”
+ “I received your voicemail and wanted to follow up.\n\n”
+ “Please let me know how you’d like to proceed, “
+ “or if you’d prefer to schedule a call, “
+ “I’m happy to find a time that works.\n\n”
+ “Best regards,\nJohn”,
“sms” =>
greeting + “\n\n”
+ $”I’m reaching out {topicRef} in response to your text.\n\n”
+ (!string.IsNullOrEmpty(phoneBody)
? $”I received your text message: \”{phoneBody.Trim()}\”\n\n”
: “I received your text message and wanted to follow up.\n\n”)
+ “Could you please let me know how you’d like to proceed? “
+ “I’m happy to discuss further by email or schedule a call.\n\n”
+ “Best regards,\nJohn”,
_ => greeting + “\n\nThank you for reaching out. “
+ “I wanted to follow up on our conversation.\n\n”
+ “Best regards,\nJohn”
Key detail: The topicRef references the original email subject. This connects the follow-up to the right context. When Jane from Acme Corp calls about the “Senior Developer” position, the follow-up says “I noticed that you called me regarding ‘Senior Developer Position'” — the caller immediately knows this is a relevant reply, not spam.
Step 5: Detect Scheduling Intent and Include Calendly Availability
This is where Part 2 (Calendly integration) connects in. If the phone interaction suggests the caller wanted to schedule a meeting, we automatically include real calendar availability in the follow-up email:
static readonly Regex SchedulingKeywords = new(
@”\b(call|schedule|interview|meeting|availability|available|” +
@”talk|speak|discuss|chat|connect|time)\b”,
RegexOptions.Compiled | RegexOptions.IgnoreCase);
static bool ShouldIncludeAvailability(string type, string? body)
{
// Missed calls always imply they wanted to talk
if (type == “missed_call”)
return true;
// For voicemails and SMS, check if content mentions scheduling
if (!string.IsNullOrEmpty(body) && SchedulingKeywords.IsMatch(body))
return true;
return false;
}
// In the follow-up processing loop:
if (ShouldIncludeAvailability(log.Type, log.Body))
{
// Reuse the CalendlyService from Part 2
string availability = await calendlyService.GetAvailability();
replyBody += “\n\n” + availability;
}
The result: a missed call follow-up email that includes lines like:
Hi Jane,
I noticed that you called me regarding “Senior Developer Position”. I’m sorry I missed your call.
Could you please let me know what the call was about, or if you’d like to schedule a time to connect? I’m happy to jump on a call at your convenience.
Here are my available timeslots (Eastern Time):
Monday, April 7: 9:00 AM, 12:00 PM, 4:30 PM
Tuesday, April 8: 10:00 AM, 1:00 PM, 4:00 PM
Wednesday, April 9: 9:30 AM, 2:00 PM, 4:30 PM
Best regards,
John
Step 6: Avoid Duplicate Follow-Ups
A critical edge case: what if the person called before the last email was exchanged? That means the conversation already moved forward — sending a “sorry I missed your call” follow-up would be confusing. We check timing:
// Check if the phone interaction happened after the last email activity
var lastEmailActivity = await emailRepo.GetLastActivityDateAsync(senderEmail);
if (lastEmailActivity != null && log.CreatedAt <= lastEmailActivity.Value)
{
// Phone call was before the last email — conversation already progressed
await phoneLogRepo.MarkProcessedAsync(log.Id);
continue;
}
The GetLastActivityDateAsync query checks both received and sent emails:
SELECT MAX(LastDate) FROM (
SELECT MAX(e.[Date]) AS LastDate FROM Emails e
WHERE e.[From] LIKE ‘%’ + @Email + ‘%’
UNION ALL
SELECT MAX(s.SentAt) AS LastDate FROM SentEmails s
WHERE s.[To] LIKE ‘%’ + @Email + ‘%’
) AS Combined
Step 7: Thread the Reply into the Existing Conversation
To make the follow-up look like a natural continuation of the email thread, we use the original email’s subject with a “RE:” prefix and include the original message below:
// Continue the email thread
string subject = latestEmail.Subject.StartsWith(“RE:”,
StringComparison.OrdinalIgnoreCase)
? latestEmail.Subject
: “RE: ” + latestEmail.Subject;
string fullBody = replyBody
+ “\n\n— Original Message —\n”
+ $”From: {latestEmail.From}\n”
+ $”Date: {latestEmail.Date:yyyy-MM-dd HH:mm}\n”
+ $”Subject: {latestEmail.Subject}\n\n”
+ latestEmail.Body;
The Complete Flow in Action
Here’s a real-world scenario showing all three parts of the series working together:
Day 1, 9:00 AM — Email arrives (Part 1)
From: jane@acmestaffing.com
Subject: Senior C# Developer — Remote
Hi, we have a great opportunity for a Senior C# Developer. Would love to discuss.
Jane Smith
Acme Staffing | (609) 436-7850
jane@acmestaffing.com
System action: Email is saved to database. Phone number 6094367850 is extracted from signature and stored in SendersPhoneOffice. Claude generates a reply expressing interest (Part 1).
Day 1, 2:30 PM — Missed call (Part 3, this post)
Missed call from: +16094367850
Duration: 0 seconds
Time: 2:30 PM ET
System action:
1. VoIP sync pulls the missed call into PhoneLogs
2. Normalizes +16094367850 → 6094367850
3. Searches Emails → finds the “Senior C# Developer” thread (phone matches SendersPhoneOffice)
4. Detects it’s a missed call → includes Calendly availability (Part 2)
5. Generates and sends follow-up email
Auto-generated follow-up email
To: jane@acmestaffing.com
Subject: RE: Senior C# Developer — Remote
Hi Jane,
I noticed that you called me regarding “Senior C# Developer — Remote”. I’m sorry I missed your call.
Could you please let me know what the call was about, or if you’d like to schedule a time to connect? I’m happy to jump on a call at your convenience.
Here are my available timeslots (Eastern Time):
Monday, April 7: 9:00 AM, 12:00 PM, 4:30 PM
Tuesday, April 8: 10:00 AM, 1:00 PM, 4:00 PM
Best regards,
John
Day 2, 9:15 AM — Reply confirming time (Part 2)
From: jane@acmestaffing.com
Subject: RE: Senior C# Developer — Remote
Tuesday at 10 AM works. Talk then!
System action: Claude detects time confirmation → ACTION: SCHEDULE_MEETING. System finds Calendly slot and sends booking link (Part 2).
Tips and Best Practices
1. Normalize Phone Numbers Consistently
Phone numbers come in many formats: (609) 436-7850, +1-609-436-7850, 6094367850. Always strip to the last 10 digits for comparison. This one function prevents most matching failures.
2. Search Multiple Fields
Don’t rely solely on dedicated phone columns. Search the email body too — phone numbers often appear in email text (“call me at…”) without being in the signature block that your parser targets.
3. Use Time-Based Deduplication
Always check whether the phone interaction is newer than the last email activity. Without this check, you’ll send awkward follow-ups for calls that were already addressed in subsequent emails.
4. Classify Before Responding
Missed calls, voicemails, and SMS all need different response tones. A missed call follow-up should be inquisitive (“what was the call about?”).
A voicemail follow-up should acknowledge the message. An SMS follow-up can quote the text directly.
5. Add an Approval Queue
Phone follow-ups are more prone to false matches than direct email replies. Consider routing them through an approval step (as described in Part 1) before sending, especially when you’re first deploying the system.
6. Include Availability Strategically
Don’t attach Calendly availability to every follow-up. Missed calls strongly imply scheduling intent. For SMS and voicemails, only include availability when the content contains scheduling keywords.
Conclusion
This three-part series demonstrates how to build a unified communication agent that works across email, phone, and scheduling:
- Part 1 handles the email channel — reading, analyzing, and replying with Claude
- Part 2 adds scheduling intelligence — detecting availability requests and booking meetings via Calendly
- Part 3 bridges the phone channel — syncing VoIP logs, cross-referencing callers to email threads by phone number, and generating contextual follow-ups that feed back into the email conversation
The key architectural insight is that phone numbers are the bridge between channels. By extracting them from email signatures at save time and normalizing them consistently, you can connect a missed call at 2:30 PM to the right email thread in milliseconds — and respond with context the caller recognizes.
All three components share the same database, the same email sending infrastructure, and the same Calendly integration. Adding a new channel (live chat, WhatsApp, LinkedIn messages) follows the same pattern: sync the data, find the phone or email match, and generate a contextual follow-up.