Seth Claude Sonnet 5 commited on
Commit
56eda2f
·
1 Parent(s): 810c33d

Allow LinkedIn generation for contacts with no email but a LinkedIn URL

Browse files

Apollo 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>

backend/app/gpt_service.py CHANGED
@@ -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
- first_name = safe_str(contact.get("first_name") or contact.get("First Name") or "")
171
- last_name = safe_str(contact.get("last_name") or contact.get("Last Name") or "")
172
- company = safe_str(contact.get("company") or contact.get("Company") or contact.get("Organization") or "")
173
- email = safe_str(contact.get("email") or contact.get("Email") or "")
174
- title = safe_str(contact.get("title") or contact.get("Title") or contact.get("Job Title") or "")
175
- industry = safe_str(contact.get("industry") or contact.get("Industry") or "")
176
- location = safe_str(contact.get("location") or contact.get("Location") or "")
177
- company_size = safe_str(contact.get("company_size") or contact.get("Company Size") or "")
 
 
 
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
- print(f"Error generating sequence: {e}")
490
- # Return a fallback email
491
- first_name = contact.get("first_name", contact.get("First Name", "there"))
492
- company = contact.get("company", contact.get("Company", contact.get("Organization", "your company")))
493
- fb = (sender_mailbox_name or "").strip()
494
- tail = f"\n\nBest Regards\n{fb}" if fb else "\n\nBest Regards"
495
-
496
- return [{
497
- "first_name": first_name,
498
- "last_name": contact.get("last_name", contact.get("Last Name", "")),
499
- "email": contact.get("email", contact.get("Email", "")),
500
- "company": company,
501
- "title": contact.get("title", contact.get("Title", contact.get("Job Title", ""))),
502
- "product": product_name,
503
- "email_number": 1,
504
- "subject": f"{first_name}, let's talk about {product_name}",
505
- "email_content": f"Hi {first_name},\n\nI wanted to reach out about how {product_name} could benefit {company}.{tail}",
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 = safe_str(contact.get("first_name") or contact.get("First Name") or "")
533
- last_name = safe_str(contact.get("last_name") or contact.get("Last Name") or "")
534
- company = safe_str(contact.get("company") or contact.get("Company") or contact.get("Organization") or "")
535
- email = safe_str(contact.get("email") or contact.get("Email") or "")
536
- title = safe_str(contact.get("title") or contact.get("Title") or contact.get("Job Title") or "")
537
- industry = safe_str(contact.get("industry") or contact.get("Industry") or "")
538
- location = safe_str(contact.get("location") or contact.get("Location") or "")
 
 
 
 
 
 
 
 
539
 
540
- if not email:
541
- raise ValueError(f"Contact missing email address: {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
- safe_str(contact.get("email") or contact.get("Email") or ""), product_name, e,
744
  )
745
  return [
746
  {
747
  "generation_failed": True,
748
  "error": str(e),
749
- "email": safe_str(contact.get("email") or contact.get("Email") or ""),
 
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
 
backend/app/main.py CHANGED
@@ -5388,49 +5388,53 @@ async def generate_sequences(
5388
  product_name,
5389
  )
5390
 
5391
- for i, seq_data in enumerate(sequence_data_list):
5392
- step_order_v = None
5393
- if plan_pack and gmail_specs and i < len(gmail_specs):
5394
- step_order_v = gmail_specs[i]["step_order"]
5395
- db_sequence = GeneratedSequence(
5396
- tenant_id=tenant_id,
5397
- file_id=file_id,
5398
- sequence_id=sequence_id,
5399
- email_number=seq_data["email_number"],
5400
- channel="email",
5401
- first_name=seq_data["first_name"],
5402
- last_name=seq_data["last_name"],
5403
- email=seq_data["email"],
5404
- company=seq_data["company"],
5405
- title=seq_data.get("title", ""),
5406
- product=seq_data["product"],
5407
- subject=seq_data["subject"],
5408
- email_content=seq_data["email_content"],
5409
- step_order=step_order_v,
5410
- )
5411
- db.add(db_sequence)
5412
-
5413
- db.commit()
5414
-
5415
- for i, seq_data in enumerate(sequence_data_list):
5416
- step_order_v = None
5417
- if plan_pack and gmail_specs and i < len(gmail_specs):
5418
- step_order_v = gmail_specs[i]["step_order"]
5419
- sequence_response = {
5420
- "id": sequence_id,
5421
- "emailNumber": seq_data["email_number"],
5422
- "firstName": seq_data["first_name"],
5423
- "lastName": seq_data["last_name"],
5424
- "email": seq_data["email"],
5425
- "company": seq_data["company"],
5426
- "title": seq_data.get("title", ""),
5427
- "product": seq_data["product"],
5428
- "subject": seq_data["subject"],
5429
- "emailContent": seq_data["email_content"],
5430
- "channel": "email",
5431
- "stepOrder": step_order_v,
5432
- }
5433
- yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
 
 
 
 
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):
frontend/src/components/campaigns/CreateCampaignWizard.jsx CHANGED
@@ -796,7 +796,12 @@ export default function CreateCampaignWizard({
796
  } else if (data.type === 'contact_error') {
797
  setGenFailedContacts((prev) => [
798
  ...prev,
799
- { email: data.email || '', channel: data.channel || '', error: data.error || '' },
 
 
 
 
 
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>
frontend/src/lib/mergeSequenceIntoContacts.js CHANGED
@@ -5,12 +5,18 @@
5
  */
6
  export function mergeSequenceIntoContacts(prev, sequence) {
7
  const ch = sequence.channel || 'email';
8
- const matchIdx = prev.findIndex(
9
- (c) =>
10
- c.firstName === sequence.firstName &&
11
- c.lastName === sequence.lastName &&
12
- c.email === sequence.email
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;