@@ -257,31 +257,71 @@ func backfillEmailsForUser(ctx context.Context, auth0UserID, username string, dr
257257 return fmt .Errorf ("parsing email SFIDs for %s: %w" , userSfid , err )
258258 }
259259
260+ // Single pass: collect candidate (non-primary, active, verified) emails to
261+ // link, while also counting "qualifying" emails (active and either
262+ // verified or primary) across ALL of the user's alternate email rows. If
263+ // only one email qualifies, that row is a lone alternate_email__c acting
264+ // as a de-facto primary — v1 lazy-sync may not have created/synced the
265+ // primary row yet — so it should not be linked as a secondary identity,
266+ // even if it was collected as a link candidate (see LFXV2-2662; this
267+ // mirrors auth0-db-sync.js's heuristic).
268+ type emailCandidate struct {
269+ emailSfid string
270+ email string
271+ }
272+ var candidates []emailCandidate
273+ qualifying := 0
274+ sawPrimary := false
260275 for _ , emailSfid := range emailSfids {
261276 email , isPrimary , isVerified , isActive , err := getAlternateEmailDetailsFn (ctx , emailSfid )
262277 if err != nil {
263- logger .With ("error" , err , "email_sfid" , emailSfid , "auth0_user_id" , auth0UserID ).
264- Warn ("failed to get alternate email details, skipping" )
265- result .emailsSkipped ++
266- continue
278+ // A read failure makes the qualifying count unreliable, and
279+ // guessing either way (sole vs. non-sole) risks a wrong link
280+ // decision, so abort rather than silently proceed. This user
281+ // will be retried on the next run (the cursor doesn't advance
282+ // past them); a persistently-bad row requires a manual data fix
283+ // to unblock, which is preferable to silently mislinking.
284+ return fmt .Errorf ("getting alternate email details for %s (auth0 user %s): %w" , emailSfid , auth0UserID , err )
285+ }
286+ if isActive && (isVerified || isPrimary ) {
287+ qualifying ++
288+ if isPrimary {
289+ sawPrimary = true
290+ }
267291 }
268292 if isPrimary || ! isActive || ! isVerified || email == "" {
269293 result .emailsSkipped ++
270294 continue
271295 }
296+ candidates = append (candidates , emailCandidate {emailSfid : emailSfid , email : email })
297+ }
272298
299+ if qualifying <= 1 {
300+ logger .With ("user_sfid" , userSfid , "auth0_user_id" , auth0UserID ).
301+ Debug ("sole qualifying alternate email, treating as de-facto primary, skipping link candidates" )
302+ result .emailsSkipped += len (candidates )
303+ return nil
304+ }
305+ if ! sawPrimary {
306+ // More than one row qualifies but none is flagged primary: which one
307+ // is the de-facto primary is genuinely ambiguous, so abort rather
308+ // than linking all of them as secondary identities.
309+ return fmt .Errorf ("user %s (auth0 user %s) has %d qualifying alternate emails and none is flagged primary; cannot determine de-facto primary" , userSfid , auth0UserID , qualifying )
310+ }
311+
312+ for _ , c := range candidates {
273313 if dryRun {
274314 logger .With (
275315 "auth0_user_id" , auth0UserID ,
276- "email" , email ,
277- "email_sfid" , emailSfid ,
316+ "email" , c . email ,
317+ "email_sfid" , c . emailSfid ,
278318 ).Info ("[dry-run] would link alternate email to Auth0 user" )
279319 result .emailsLinked ++
280320 continue
281321 }
282322
283- if err := linkEmailIdentityFn (ctx , auth0UserID , email ); err != nil {
284- return fmt .Errorf ("linking email %s: %w" , email , err )
323+ if err := linkEmailIdentityFn (ctx , auth0UserID , c . email ); err != nil {
324+ return fmt .Errorf ("linking email %s: %w" , c . email , err )
285325 }
286326 result .emailsLinked ++
287327 }
@@ -474,11 +514,36 @@ func syncSingleUser(ctx context.Context, username string, dryRun bool) error {
474514 return fmt .Errorf ("parsing email SFIDs: %w" , err )
475515 }
476516
517+ // Single pass: collect candidate (non-primary, active, verified) emails to
518+ // link, while also counting "qualifying" emails (active and either
519+ // verified or primary) across ALL of the user's alternate email rows. If
520+ // only one email qualifies, that row is a lone alternate_email__c acting
521+ // as a de-facto primary — v1 lazy-sync may not have created/synced the
522+ // primary row yet — so it should not be linked as a secondary identity,
523+ // even if it was collected as a link candidate (see LFXV2-2662; this
524+ // mirrors auth0-db-sync.js's heuristic).
525+ type emailCandidate struct {
526+ emailSfid string
527+ email string
528+ }
529+ var candidates []emailCandidate
530+ qualifying := 0
531+ sawPrimary := false
477532 for _ , emailSfid := range emailSfids {
478533 email , isPrimary , isVerified , isActive , err := getAlternateEmailDetailsFn (ctx , emailSfid )
479534 if err != nil {
480- logger .With ("error" , err , "email_sfid" , emailSfid ).Warn ("failed to get email details, skipping" )
481- continue
535+ // A read failure makes the qualifying count unreliable, and
536+ // guessing either way (sole vs. non-sole) risks a wrong link
537+ // decision, so abort rather than silently proceed. Re-run
538+ // --sync-user for this user once the underlying read problem
539+ // (or bad data) is resolved.
540+ return fmt .Errorf ("getting alternate email details for %s (auth0 user %s): %w" , emailSfid , auth0UserID , err )
541+ }
542+ if isActive && (isVerified || isPrimary ) {
543+ qualifying ++
544+ if isPrimary {
545+ sawPrimary = true
546+ }
482547 }
483548 if isPrimary || ! isActive {
484549 continue
@@ -491,18 +556,32 @@ func syncSingleUser(ctx context.Context, username string, dryRun bool) error {
491556 if email == "" {
492557 continue
493558 }
559+ candidates = append (candidates , emailCandidate {emailSfid : emailSfid , email : email })
560+ }
561+
562+ if qualifying <= 1 {
563+ logger .With ("user_sfid" , userSfid , "auth0_user_id" , auth0UserID ).
564+ Debug ("sole qualifying alternate email, treating as de-facto primary, skipping link candidates" )
565+ candidates = nil
566+ } else if ! sawPrimary {
567+ // More than one row qualifies but none is flagged primary: which one
568+ // is the de-facto primary is genuinely ambiguous, so abort rather
569+ // than linking all of them as secondary identities.
570+ return fmt .Errorf ("user %s (auth0 user %s) has %d qualifying alternate emails and none is flagged primary; cannot determine de-facto primary" , userSfid , auth0UserID , qualifying )
571+ }
494572
573+ for _ , c := range candidates {
495574 if dryRun {
496- logger .With ("auth0_user_id" , auth0UserID , "email" , email ).
575+ logger .With ("auth0_user_id" , auth0UserID , "email" , c . email ).
497576 Info ("[dry-run] would link alternate email to Auth0 user" )
498577 continue
499578 }
500579
501- if err := linkEmailIdentityFn (ctx , auth0UserID , email ); err != nil {
502- logger .With ("error" , err , "auth0_user_id" , auth0UserID , "email" , email ).
580+ if err := linkEmailIdentityFn (ctx , auth0UserID , c . email ); err != nil {
581+ logger .With ("error" , err , "auth0_user_id" , auth0UserID , "email" , c . email ).
503582 Warn ("failed to link email, skipping" )
504583 } else {
505- logger .With ("auth0_user_id" , auth0UserID , "email" , email ).
584+ logger .With ("auth0_user_id" , auth0UserID , "email" , c . email ).
506585 Info ("linked email identity" )
507586 }
508587 }
0 commit comments