Allow LinkedIn generation for contacts with no email but a LinkedIn URL
Browse filesApollo CSV exports frequently can't verify an email but still include a
valid LinkedIn profile URL — generate_linkedin_sequence() was hard-
requiring an email and failing these contacts entirely, even though
LinkedIn sending never uses email (it already resolves the profile from
the LinkedIn URL at send time). Also fixes contact field extraction
(both email and LinkedIn generation) to recognize Apollo's actual
column names like "Company Name" instead of only "Company", which was
silently leaving company blank for every Apollo-sourced contact.
- gpt_service.py: case-insensitive field lookup with the same alias
list main.py already uses elsewhere; LinkedIn generation now only
requires a name, email, or LinkedIn URL — not specifically an email.
Email generation still requires an email (needed to actually send).
- Give generate_email_sequence the same typed generation_failed marker
treatment as LinkedIn instead of silently returning fake fallback
email content on error.
- main.py: surface email-generation failures as contact_error SSE
events (previously only LinkedIn failures were surfaced this way);
include the contact's name in failure events so a no-email contact
shows up as "Jane Doe" instead of "Unknown contact".
- mergeSequenceIntoContacts.js: match generated content back to the
right contact by the stable per-row id instead of firstName+lastName
+email, so two different no-email contacts can never collide.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@@ -16,6 +16,24 @@ logger = logging.getLogger(__name__)
|
|
| 16 |
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
| 17 |
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
def normalize_email_body_format(
|
| 20 |
body: str,
|
| 21 |
*,
|
|
@@ -166,15 +184,18 @@ def generate_email_sequence(
|
|
| 166 |
return str(value).strip()
|
| 167 |
return str(value).strip() if value else ""
|
| 168 |
|
| 169 |
-
# Extract variables from contact (handle empty strings, None, and float/NaN values
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
| 178 |
signoff = (sender_mailbox_name or "").strip()
|
| 179 |
|
| 180 |
# Validate required fields
|
|
@@ -486,24 +507,28 @@ Touches in order:
|
|
| 486 |
} for email_data in emails]
|
| 487 |
|
| 488 |
except Exception as e:
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
"
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
|
| 508 |
|
| 509 |
def generate_linkedin_sequence(
|
|
@@ -529,16 +554,24 @@ def generate_linkedin_sequence(
|
|
| 529 |
return str(value).strip() if value else ""
|
| 530 |
|
| 531 |
try:
|
| 532 |
-
first_name =
|
| 533 |
-
last_name =
|
| 534 |
-
company =
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
|
| 540 |
-
if not email:
|
| 541 |
-
raise ValueError(f"Contact
|
| 542 |
|
| 543 |
li_sign = (sender_linkedin_name or "").strip()
|
| 544 |
|
|
@@ -738,15 +771,26 @@ Use the contact's real first name where a greeting is appropriate. Do not use pl
|
|
| 738 |
raise ValueError("LinkedIn sequence generation produced no usable messages after parsing")
|
| 739 |
return out
|
| 740 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
logger.exception(
|
| 742 |
"LinkedIn sequence generation failed for contact %s (product=%s): %s",
|
| 743 |
-
|
| 744 |
)
|
| 745 |
return [
|
| 746 |
{
|
| 747 |
"generation_failed": True,
|
| 748 |
"error": str(e),
|
| 749 |
-
"email":
|
|
|
|
| 750 |
}
|
| 751 |
]
|
| 752 |
|
|
|
|
| 16 |
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
| 17 |
|
| 18 |
|
| 19 |
+
def _pick_contact_field(contact: Dict, aliases: List[str]) -> str:
|
| 20 |
+
"""Case-insensitive lookup across CSV column name variants (e.g. Apollo exports use
|
| 21 |
+
"Company Name" / "Company Name for Emails" rather than a plain "Company" column)."""
|
| 22 |
+
if not isinstance(contact, dict):
|
| 23 |
+
return ""
|
| 24 |
+
lowered = {str(k).strip().lower(): v for k, v in contact.items()}
|
| 25 |
+
for alias in aliases:
|
| 26 |
+
val = lowered.get(alias.lower())
|
| 27 |
+
if val is None:
|
| 28 |
+
continue
|
| 29 |
+
if isinstance(val, float) and math.isnan(val):
|
| 30 |
+
continue
|
| 31 |
+
s = str(val).strip()
|
| 32 |
+
if s and s.lower() != "nan":
|
| 33 |
+
return s
|
| 34 |
+
return ""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
def normalize_email_body_format(
|
| 38 |
body: str,
|
| 39 |
*,
|
|
|
|
| 184 |
return str(value).strip()
|
| 185 |
return str(value).strip() if value else ""
|
| 186 |
|
| 187 |
+
# Extract variables from contact (handle empty strings, None, and float/NaN values,
|
| 188 |
+
# and Apollo-style column names like "Company Name" that don't match a plain "Company")
|
| 189 |
+
first_name = _pick_contact_field(contact, ["first_name", "first name", "firstname"])
|
| 190 |
+
last_name = _pick_contact_field(contact, ["last_name", "last name", "lastname"])
|
| 191 |
+
company = _pick_contact_field(
|
| 192 |
+
contact, ["company", "company name", "company name for emails", "organization", "organization name", "account name"]
|
| 193 |
+
)
|
| 194 |
+
email = _pick_contact_field(contact, ["email", "work email", "email address"])
|
| 195 |
+
title = _pick_contact_field(contact, ["title", "job title"])
|
| 196 |
+
industry = _pick_contact_field(contact, ["industry"])
|
| 197 |
+
location = _pick_contact_field(contact, ["location", "address", "city"])
|
| 198 |
+
company_size = _pick_contact_field(contact, ["company_size", "company size", "# employees"])
|
| 199 |
signoff = (sender_mailbox_name or "").strip()
|
| 200 |
|
| 201 |
# Validate required fields
|
|
|
|
| 507 |
} for email_data in emails]
|
| 508 |
|
| 509 |
except Exception as e:
|
| 510 |
+
contact_email = _pick_contact_field(contact, ["email", "work email", "email address"])
|
| 511 |
+
contact_name = " ".join(
|
| 512 |
+
filter(
|
| 513 |
+
None,
|
| 514 |
+
[
|
| 515 |
+
_pick_contact_field(contact, ["first_name", "first name", "firstname"]),
|
| 516 |
+
_pick_contact_field(contact, ["last_name", "last name", "lastname"]),
|
| 517 |
+
],
|
| 518 |
+
)
|
| 519 |
+
)
|
| 520 |
+
logger.exception(
|
| 521 |
+
"Email sequence generation failed for contact %s (product=%s): %s",
|
| 522 |
+
contact_email or contact_name or "unknown", product_name, e,
|
| 523 |
+
)
|
| 524 |
+
return [
|
| 525 |
+
{
|
| 526 |
+
"generation_failed": True,
|
| 527 |
+
"error": str(e),
|
| 528 |
+
"email": contact_email,
|
| 529 |
+
"name": contact_name,
|
| 530 |
+
}
|
| 531 |
+
]
|
| 532 |
|
| 533 |
|
| 534 |
def generate_linkedin_sequence(
|
|
|
|
| 554 |
return str(value).strip() if value else ""
|
| 555 |
|
| 556 |
try:
|
| 557 |
+
first_name = _pick_contact_field(contact, ["first_name", "first name", "firstname"])
|
| 558 |
+
last_name = _pick_contact_field(contact, ["last_name", "last name", "lastname"])
|
| 559 |
+
company = _pick_contact_field(
|
| 560 |
+
contact, ["company", "company name", "company name for emails", "organization", "organization name", "account name"]
|
| 561 |
+
)
|
| 562 |
+
# Email is NOT required for LinkedIn outreach — Apollo exports frequently have a valid
|
| 563 |
+
# LinkedIn profile but an unverified/missing email, and sending uses the LinkedIn URL
|
| 564 |
+
# (handled separately at send time), not the email address.
|
| 565 |
+
email = _pick_contact_field(contact, ["email", "work email", "email address"])
|
| 566 |
+
title = _pick_contact_field(contact, ["title", "job title"])
|
| 567 |
+
industry = _pick_contact_field(contact, ["industry"])
|
| 568 |
+
location = _pick_contact_field(contact, ["location", "address", "city"])
|
| 569 |
+
linkedin_url = _pick_contact_field(
|
| 570 |
+
contact, ["person linkedin url", "linkedin url", "linkedin_url", "li_url", "profile_url"]
|
| 571 |
+
)
|
| 572 |
|
| 573 |
+
if not first_name and not last_name and not linkedin_url and not email:
|
| 574 |
+
raise ValueError(f"Contact has no name, email, or LinkedIn URL to personalize against: {contact}")
|
| 575 |
|
| 576 |
li_sign = (sender_linkedin_name or "").strip()
|
| 577 |
|
|
|
|
| 771 |
raise ValueError("LinkedIn sequence generation produced no usable messages after parsing")
|
| 772 |
return out
|
| 773 |
except Exception as e:
|
| 774 |
+
contact_email = _pick_contact_field(contact, ["email", "work email", "email address"])
|
| 775 |
+
contact_name = " ".join(
|
| 776 |
+
filter(
|
| 777 |
+
None,
|
| 778 |
+
[
|
| 779 |
+
_pick_contact_field(contact, ["first_name", "first name", "firstname"]),
|
| 780 |
+
_pick_contact_field(contact, ["last_name", "last name", "lastname"]),
|
| 781 |
+
],
|
| 782 |
+
)
|
| 783 |
+
)
|
| 784 |
logger.exception(
|
| 785 |
"LinkedIn sequence generation failed for contact %s (product=%s): %s",
|
| 786 |
+
contact_email or contact_name or "unknown", product_name, e,
|
| 787 |
)
|
| 788 |
return [
|
| 789 |
{
|
| 790 |
"generation_failed": True,
|
| 791 |
"error": str(e),
|
| 792 |
+
"email": contact_email,
|
| 793 |
+
"name": contact_name,
|
| 794 |
}
|
| 795 |
]
|
| 796 |
|
|
@@ -5388,49 +5388,53 @@ async def generate_sequences(
|
|
| 5388 |
product_name,
|
| 5389 |
)
|
| 5390 |
|
| 5391 |
-
|
| 5392 |
-
|
| 5393 |
-
|
| 5394 |
-
|
| 5395 |
-
|
| 5396 |
-
|
| 5397 |
-
|
| 5398 |
-
|
| 5399 |
-
|
| 5400 |
-
|
| 5401 |
-
|
| 5402 |
-
|
| 5403 |
-
|
| 5404 |
-
|
| 5405 |
-
|
| 5406 |
-
|
| 5407 |
-
|
| 5408 |
-
|
| 5409 |
-
|
| 5410 |
-
|
| 5411 |
-
|
| 5412 |
-
|
| 5413 |
-
|
| 5414 |
-
|
| 5415 |
-
|
| 5416 |
-
|
| 5417 |
-
|
| 5418 |
-
|
| 5419 |
-
|
| 5420 |
-
|
| 5421 |
-
|
| 5422 |
-
|
| 5423 |
-
|
| 5424 |
-
|
| 5425 |
-
|
| 5426 |
-
|
| 5427 |
-
|
| 5428 |
-
|
| 5429 |
-
|
| 5430 |
-
|
| 5431 |
-
|
| 5432 |
-
|
| 5433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5434 |
|
| 5435 |
prog = plan_progress_pct() if plan_pack else progress_pct(sequence_id, linkedin_phase=False)
|
| 5436 |
yield f"data: {json.dumps({'type': 'progress', 'progress': prog})}\n\n"
|
|
@@ -5528,7 +5532,7 @@ async def generate_sequences(
|
|
| 5528 |
|
| 5529 |
li_failed = bool(li_list) and li_list[0].get("generation_failed")
|
| 5530 |
if li_failed:
|
| 5531 |
-
yield f"data: {json.dumps({'type': 'contact_error', 'channel': 'linkedin', 'sequence_id': sequence_id, 'email': li_list[0].get('email', ''), 'error': li_list[0].get('error', '')})}\n\n"
|
| 5532 |
else:
|
| 5533 |
def _step_order_for(seq_data):
|
| 5534 |
if not (plan_pack and li_meta_for_rows):
|
|
|
|
| 5388 |
product_name,
|
| 5389 |
)
|
| 5390 |
|
| 5391 |
+
email_failed = bool(sequence_data_list) and sequence_data_list[0].get("generation_failed")
|
| 5392 |
+
if email_failed:
|
| 5393 |
+
yield f"data: {json.dumps({'type': 'contact_error', 'channel': 'email', 'sequence_id': sequence_id, 'email': sequence_data_list[0].get('email', ''), 'name': sequence_data_list[0].get('name', ''), 'error': sequence_data_list[0].get('error', '')})}\n\n"
|
| 5394 |
+
else:
|
| 5395 |
+
for i, seq_data in enumerate(sequence_data_list):
|
| 5396 |
+
step_order_v = None
|
| 5397 |
+
if plan_pack and gmail_specs and i < len(gmail_specs):
|
| 5398 |
+
step_order_v = gmail_specs[i]["step_order"]
|
| 5399 |
+
db_sequence = GeneratedSequence(
|
| 5400 |
+
tenant_id=tenant_id,
|
| 5401 |
+
file_id=file_id,
|
| 5402 |
+
sequence_id=sequence_id,
|
| 5403 |
+
email_number=seq_data["email_number"],
|
| 5404 |
+
channel="email",
|
| 5405 |
+
first_name=seq_data["first_name"],
|
| 5406 |
+
last_name=seq_data["last_name"],
|
| 5407 |
+
email=seq_data["email"],
|
| 5408 |
+
company=seq_data["company"],
|
| 5409 |
+
title=seq_data.get("title", ""),
|
| 5410 |
+
product=seq_data["product"],
|
| 5411 |
+
subject=seq_data["subject"],
|
| 5412 |
+
email_content=seq_data["email_content"],
|
| 5413 |
+
step_order=step_order_v,
|
| 5414 |
+
)
|
| 5415 |
+
db.add(db_sequence)
|
| 5416 |
+
|
| 5417 |
+
db.commit()
|
| 5418 |
+
|
| 5419 |
+
for i, seq_data in enumerate(sequence_data_list):
|
| 5420 |
+
step_order_v = None
|
| 5421 |
+
if plan_pack and gmail_specs and i < len(gmail_specs):
|
| 5422 |
+
step_order_v = gmail_specs[i]["step_order"]
|
| 5423 |
+
sequence_response = {
|
| 5424 |
+
"id": sequence_id,
|
| 5425 |
+
"emailNumber": seq_data["email_number"],
|
| 5426 |
+
"firstName": seq_data["first_name"],
|
| 5427 |
+
"lastName": seq_data["last_name"],
|
| 5428 |
+
"email": seq_data["email"],
|
| 5429 |
+
"company": seq_data["company"],
|
| 5430 |
+
"title": seq_data.get("title", ""),
|
| 5431 |
+
"product": seq_data["product"],
|
| 5432 |
+
"subject": seq_data["subject"],
|
| 5433 |
+
"emailContent": seq_data["email_content"],
|
| 5434 |
+
"channel": "email",
|
| 5435 |
+
"stepOrder": step_order_v,
|
| 5436 |
+
}
|
| 5437 |
+
yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
|
| 5438 |
|
| 5439 |
prog = plan_progress_pct() if plan_pack else progress_pct(sequence_id, linkedin_phase=False)
|
| 5440 |
yield f"data: {json.dumps({'type': 'progress', 'progress': prog})}\n\n"
|
|
|
|
| 5532 |
|
| 5533 |
li_failed = bool(li_list) and li_list[0].get("generation_failed")
|
| 5534 |
if li_failed:
|
| 5535 |
+
yield f"data: {json.dumps({'type': 'contact_error', 'channel': 'linkedin', 'sequence_id': sequence_id, 'email': li_list[0].get('email', ''), 'name': li_list[0].get('name', ''), 'error': li_list[0].get('error', '')})}\n\n"
|
| 5536 |
else:
|
| 5537 |
def _step_order_for(seq_data):
|
| 5538 |
if not (plan_pack and li_meta_for_rows):
|
|
@@ -796,7 +796,12 @@ export default function CreateCampaignWizard({
|
|
| 796 |
} else if (data.type === 'contact_error') {
|
| 797 |
setGenFailedContacts((prev) => [
|
| 798 |
...prev,
|
| 799 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
]);
|
| 801 |
} else if (data.type === 'preview_complete') {
|
| 802 |
setGenRunning(false);
|
|
@@ -1254,8 +1259,8 @@ export default function CreateCampaignWizard({
|
|
| 1254 |
</p>
|
| 1255 |
<ul className="mt-1.5 max-h-24 space-y-0.5 overflow-y-auto text-xs text-amber-800">
|
| 1256 |
{genFailedContacts.slice(0, 10).map((f, i) => (
|
| 1257 |
-
<li key={`${f.email}-${i}`}>
|
| 1258 |
-
{f.email || 'Unknown contact'}
|
| 1259 |
{f.channel ? ` (${f.channel})` : ''}
|
| 1260 |
{f.error ? `: ${f.error}` : ''}
|
| 1261 |
</li>
|
|
|
|
| 796 |
} else if (data.type === 'contact_error') {
|
| 797 |
setGenFailedContacts((prev) => [
|
| 798 |
...prev,
|
| 799 |
+
{
|
| 800 |
+
email: data.email || '',
|
| 801 |
+
name: data.name || '',
|
| 802 |
+
channel: data.channel || '',
|
| 803 |
+
error: data.error || '',
|
| 804 |
+
},
|
| 805 |
]);
|
| 806 |
} else if (data.type === 'preview_complete') {
|
| 807 |
setGenRunning(false);
|
|
|
|
| 1259 |
</p>
|
| 1260 |
<ul className="mt-1.5 max-h-24 space-y-0.5 overflow-y-auto text-xs text-amber-800">
|
| 1261 |
{genFailedContacts.slice(0, 10).map((f, i) => (
|
| 1262 |
+
<li key={`${f.email || f.name}-${i}`}>
|
| 1263 |
+
{f.email || f.name || 'Unknown contact'}
|
| 1264 |
{f.channel ? ` (${f.channel})` : ''}
|
| 1265 |
{f.error ? `: ${f.error}` : ''}
|
| 1266 |
</li>
|
|
@@ -5,12 +5,18 @@
|
|
| 5 |
*/
|
| 6 |
export function mergeSequenceIntoContacts(prev, sequence) {
|
| 7 |
const ch = sequence.channel || 'email';
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
const stepOrder =
|
| 16 |
sequence.stepOrder != null && sequence.stepOrder !== undefined ? sequence.stepOrder : null;
|
|
|
|
| 5 |
*/
|
| 6 |
export function mergeSequenceIntoContacts(prev, sequence) {
|
| 7 |
const ch = sequence.channel || 'email';
|
| 8 |
+
// `id` (the CSV row's sequence_id) is a stable per-contact identifier that's always present
|
| 9 |
+
// and unique within a run — prefer it so contacts without an email (common for Apollo rows
|
| 10 |
+
// with only a LinkedIn URL) never collide with another no-email contact sharing the same name.
|
| 11 |
+
const matchIdx =
|
| 12 |
+
sequence.id != null
|
| 13 |
+
? prev.findIndex((c) => c.id === sequence.id)
|
| 14 |
+
: prev.findIndex(
|
| 15 |
+
(c) =>
|
| 16 |
+
c.firstName === sequence.firstName &&
|
| 17 |
+
c.lastName === sequence.lastName &&
|
| 18 |
+
c.email === sequence.email
|
| 19 |
+
);
|
| 20 |
|
| 21 |
const stepOrder =
|
| 22 |
sequence.stepOrder != null && sequence.stepOrder !== undefined ? sequence.stepOrder : null;
|