Guide

Map Organization Contacts from Email

Most CRMs show you what people entered manually. Your inbox shows you what actually happened — every email sent, every meeting held, every CC that reveals a relationship. This guide combines all the techniques from the previous guides into a complete external contact map with relationship strength scoring.

What a contact map reveals

A contact map built from email and calendar data reveals four things that no manually-maintained CRM can show you:

  • Who knows whom at which external company — not based on what someone typed into a CRM field, but on actual email threads and calendar invites. If Alice has exchanged 47 emails with Bob at Acme, that relationship exists whether or not it was logged.
  • How strong each relationship is — not just that a relationship exists, but whether it is active or dormant, one-way or reciprocal, email-only or reinforced by meetings. A contact you emailed once six months ago is different from one you meet weekly.
  • Where you have single-threaded risk — if only one person on your team has a relationship with a key account, that account is at risk. When that person goes on vacation, changes roles, or leaves, the relationship goes with them.
  • Warm introduction paths — Alice knows Bob at Acme with a strength of 85. Bob has been in meetings with Carol, the VP you need to reach. Alice can make a warm introduction through Bob — and you can see this path in the data.

Aggregate across accounts

A contact map for one person is useful. A contact map across your team is transformative. Start by exporting email data from each team member's account:

# Export each team member's email data
nylas email list --grant-id alice@yourcompany.com --json --limit 500 > alice_emails.json
nylas email list --grant-id bob@yourcompany.com --json --limit 500 > bob_emails.json
nylas email list --grant-id carol@yourcompany.com --json --limit 500 > carol_emails.json

# Combine all exports into one file
jq -s 'add' alice_emails.json bob_emails.json carol_emails.json > all_emails.json

# Do the same for calendar events
nylas calendar events list --grant-id alice@yourcompany.com --json --limit 200 > alice_events.json
nylas calendar events list --grant-id bob@yourcompany.com --json --limit 200 > bob_events.json
nylas calendar events list --grant-id carol@yourcompany.com --json --limit 200 > carol_events.json

jq -s 'add' alice_events.json bob_events.json carol_events.json > all_events.json

When combining data, you need a deduplication strategy. The same email thread appears in multiple exports — Alice sent it, Bob received it, Carol was CC'd. Deduplicate by message ID to avoid inflating frequency counts, but preserve the direction (sent vs. received) for each team member so you can calculate reciprocity correctly.

# Deduplicate by message ID, keeping direction metadata
cat all_emails.json | jq '
  group_by(.id) | map(.[0])
' > all_emails_deduped.json

echo "Before: $(cat all_emails.json | jq length) messages"
echo "After:  $(cat all_emails_deduped.json | jq length) unique messages"

Build a relationship strength score

A binary “knows / doesn't know” is not enough. You need a score that captures how strong each relationship is. This formula weights four signals:

# Relationship strength formula (0-100):
#   email_frequency   (messages/month)     x 0.40
#   recency           (inverse days since) x 0.25
#   reciprocity       (two-way ratio)      x 0.20
#   meeting_frequency (events/month)       x 0.15

Email frequency gets the highest weight because it is the strongest signal of an active relationship. Recency matters because a flurry of emails six months ago is less valuable than a steady exchange last week. Reciprocity distinguishes genuine relationships (both sides reply) from one-way outreach. Meetings reinforce email relationships — if two people both email and meet, the relationship is stronger.

Here is the complete Python implementation:

#!/usr/bin/env python3
"""Score relationship strength from email and calendar data."""
import json
import subprocess
from datetime import datetime, timedelta
from collections import defaultdict

def load_emails():
    result = subprocess.run(
        ["nylas", "email", "list", "--json", "--limit", "500"],
        capture_output=True, text=True, check=True
    )
    return json.loads(result.stdout)

def load_events():
    result = subprocess.run(
        ["nylas", "calendar", "events", "list", "--json", "--limit", "200"],
        capture_output=True, text=True, check=True
    )
    return json.loads(result.stdout)

def score_relationships(emails, events, my_email):
    contacts = defaultdict(lambda: {
        "sent": 0, "received": 0, "meetings": 0,
        "last_interaction": None, "name": "", "domain": ""
    })

    now = datetime.now()

    for msg in emails:
        sender = msg["from"][0]["email"]
        recipients = [r["email"] for r in msg.get("to", [])]
        msg_date = msg.get("date")
        parsed_date = None
        if msg_date:
            try:
                parsed_date = datetime.fromisoformat(
                    msg_date.replace("Z", "+00:00")
                )
            except (ValueError, TypeError):
                pass

        if sender == my_email:
            for r in recipients:
                contacts[r]["sent"] += 1
                contacts[r]["name"] = contacts[r]["name"] or next(
                    (rec.get("name", "") for rec in msg["to"]
                     if rec["email"] == r), ""
                )
                contacts[r]["domain"] = r.split("@")[1]
                if parsed_date:
                    last = contacts[r]["last_interaction"]
                    if last is None or parsed_date > last:
                        contacts[r]["last_interaction"] = parsed_date
        else:
            contacts[sender]["received"] += 1
            contacts[sender]["name"] = msg["from"][0].get("name", "")
            contacts[sender]["domain"] = sender.split("@")[1]
            if parsed_date:
                last = contacts[sender]["last_interaction"]
                if last is None or parsed_date > last:
                    contacts[sender]["last_interaction"] = parsed_date

    for event in events:
        for p in event.get("participants", []):
            email = p.get("email", "")
            if email and email != my_email:
                contacts[email]["meetings"] += 1

    scored = []
    for email, data in contacts.items():
        total = data["sent"] + data["received"]
        if total == 0:
            continue

        # Frequency: normalize to 20 emails/month = max score
        freq_score = min(total / 20, 1.0) * 40

        # Recency: full marks if last interaction within 7 days
        if data["last_interaction"]:
            days_ago = (now - data["last_interaction"].replace(
                tzinfo=None
            )).days
            recency_score = max(0, 1 - days_ago / 90) * 25
        else:
            recency_score = 0

        # Reciprocity: ratio of two-way communication
        reciprocity = (
            min(data["sent"], data["received"])
            / max(data["sent"], data["received"], 1)
        )
        recip_score = reciprocity * 20

        # Meetings: normalize to 5 meetings = max score
        meeting_score = min(data["meetings"] / 5, 1.0) * 15

        strength = round(
            freq_score + recency_score + recip_score + meeting_score
        )

        scored.append({
            "email": email,
            "name": data["name"],
            "domain": data["domain"],
            "strength": min(strength, 100),
            "emails_sent": data["sent"],
            "emails_received": data["received"],
            "meetings": data["meetings"],
            "reciprocity": round(reciprocity, 2)
        })

    return sorted(scored, key=lambda x: -x["strength"])

emails = load_emails()
events = load_events()
results = score_relationships(emails, events, "you@yourcompany.com")

# Save full results
with open("scored_contacts.json", "w") as f:
    json.dump(results, f, indent=2)

# Print top 20
print(json.dumps(results[:20], indent=2))

Run with python3 score_contacts.py. Replace you@yourcompany.com with your actual email address. The script writes the full scored list to scored_contacts.json and prints the top 20 to the terminal.

Map contacts to companies

Individual contact scores are useful, but the real insight comes when you group contacts by company. This tells you which companies you have the deepest relationships with, who your key contact is at each company, and how many people you know there:

cat scored_contacts.json | jq '
  group_by(.domain) | map({
    company: .[0].domain,
    contacts: length,
    avg_strength: ([.[].strength] | add / length | round),
    key_contact: (sort_by(-.strength) | .[0].email),
    strongest: (sort_by(-.strength) | .[0].strength),
    total_meetings: ([.[].meetings] | add),
    all_contacts: [.[] | {email, name, strength}] | sort_by(-.strength)
  }) | sort_by(-.avg_strength)'

This gives you a company-level view: the domain, how many contacts you have, the average relationship strength, your strongest contact, and the full list of people you know there. Save this output for the gap analysis in the next section:

cat scored_contacts.json | jq '
  group_by(.domain) | map({
    company: .[0].domain,
    contacts: length,
    avg_strength: ([.[].strength] | add / length | round),
    key_contact: (sort_by(-.strength) | .[0].email),
    strongest: (sort_by(-.strength) | .[0].strength)
  }) | sort_by(-.avg_strength)' > company_map.json

Identify warm introduction paths

One of the most valuable things a contact map reveals is warm introduction paths. If you need to reach someone at a target company, the question is: who on your team already knows someone there, and how strong is that connection?

The logic is straightforward. For each target company, find all team members who have a relationship with anyone at that domain. Then rank by relationship strength — the stronger the relationship, the better the introduction:

#!/usr/bin/env python3
"""Find warm introduction paths to a target company."""
import json
import sys

def find_warm_intros(scored_contacts_file, target_domain):
    with open(scored_contacts_file) as f:
        contacts = json.load(f)

    # Find all contacts at the target domain
    target_contacts = [
        c for c in contacts if c["domain"] == target_domain
    ]

    if not target_contacts:
        print(f"No existing contacts at {target_domain}")
        return

    print(f"\nWarm paths to {target_domain}:")
    print(f"{'Contact':<35} {'Strength':>8} {'Emails':>7} {'Meetings':>8}")
    print("-" * 65)

    for c in sorted(target_contacts, key=lambda x: -x["strength"]):
        print(
            f"{c['name'] or c['email']:<35} "
            f"{c['strength']:>8} "
            f"{c['emails_sent'] + c['emails_received']:>7} "
            f"{c['meetings']:>8}"
        )

    # Suggest the best introducer
    best = target_contacts[0]
    if best["strength"] >= 50:
        print(f"\nBest path: Ask for intro through {best['name'] or best['email']} "
              f"(strength {best['strength']})")
    elif best["strength"] >= 20:
        print(f"\nWeak path: {best['name'] or best['email']} "
              f"(strength {best['strength']}) — consider warming up first")
    else:
        print(f"\nCold territory: strongest contact is only {best['strength']}/100")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python3 warm_intros.py <target-domain>")
        print("Example: python3 warm_intros.py acme.com")
        sys.exit(1)

    find_warm_intros("scored_contacts.json", sys.argv[1])

Run with python3 warm_intros.py acme.com to see all your existing paths into Acme. When aggregating across a team, each team member's contacts appear in the scored list, so you can see that Alice has a strength-85 relationship with Bob at Acme while Carol only has a strength-12 — Alice is the better introducer.

Detect coverage gaps

A contact map is most valuable when it shows you what is missing. Three types of gaps matter:

Single-threaded risk: companies where only one person on your team has a relationship. If that person leaves or changes roles, you lose the account:

# Single-threaded risk: companies with only 1 contact
cat company_map.json | jq '
  [.[] | select(.contacts == 1)]
  | sort_by(-.avg_strength)
  | .[:20]
  | .[] | "\(.company): only \(.key_contact) (strength \(.strongest))"'

Declining relationships: companies where the average strength has dropped, indicating fewer recent emails. These accounts may be churning:

# Find contacts with high email count but low recency
# (lots of historical emails but nothing recent)
cat scored_contacts.json | jq '
  [.[] | select(
    (.emails_sent + .emails_received) > 10
    and .strength < 30
  )]
  | group_by(.domain)
  | map({
    company: .[0].domain,
    fading_contacts: length,
    contacts: [.[] | {email: .email, strength: .strength}]
  })
  | sort_by(-.fading_contacts)'

Decision-maker blind spots: contacts who appear only on CC lines, never in direct communication. These are often senior stakeholders who are aware of the relationship but not directly engaged:

# CC-only contacts: received emails (via CC) but never sent to directly
cat scored_contacts.json | jq '
  [.[] | select(.emails_sent == 0 and .emails_received > 0)]
  | sort_by(-.emails_received)
  | .[:20]
  | .[] | "\(.name) <\(.email)> — \(.emails_received) CCs, 0 direct"'

Export the contact map

Export the scored contact map in the format your tools need:

JSON (for programmatic consumption):

# Already saved from the scoring step
cat scored_contacts.json | jq '.' > contact_map.json

CSV (for spreadsheets and CRM import):

cat scored_contacts.json | jq -r '
  ["email","name","domain","strength","sent","received","meetings","reciprocity"],
  (.[] | [.email, .name, .domain, .strength, .emails_sent, .emails_received, .meetings, .reciprocity])
  | @csv' > contact_map.csv

DOT graph format (for visualization with Graphviz):

cat scored_contacts.json | jq -r '
  "digraph contacts {",
  "  rankdir=LR;",
  "  node [shape=box, style=filled, fillcolor=lightblue];",
  (.[] | select(.strength >= 30) |
    "  \"you\" -> \"\(.name // .email)\" [label=\"\(.strength)\", penwidth=\((.strength / 25) | tostring)];"),
  "}"' > contact_map.dot

# Render to PNG (requires Graphviz)
# dot -Tpng contact_map.dot -o contact_map.png

TypeScript version

A complete TypeScript implementation with typed interfaces for use in Node.js workflows or APIs:

import { execFileSync } from "child_process";
import { writeFileSync } from "fs";

interface RelationshipScore {
  email: string;
  name: string;
  domain: string;
  strength: number;
  emailsSent: number;
  emailsReceived: number;
  meetings: number;
  reciprocity: number;
}

interface CompanyMap {
  company: string;
  contacts: number;
  avgStrength: number;
  keyContact: string;
  strongest: number;
  allContacts: RelationshipScore[];
}

interface EmailMessage {
  id: string;
  from: { email: string; name: string }[];
  to?: { email: string; name: string }[];
  date?: string;
}

interface CalendarEvent {
  participants?: { email: string }[];
}

function loadEmails(limit = 500): EmailMessage[] {
  const output = execFileSync(
    "nylas", ["email", "list", "--json", "--limit", String(limit)],
    { encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 }
  );
  return JSON.parse(output);
}

function loadEvents(limit = 200): CalendarEvent[] {
  const output = execFileSync(
    "nylas", ["calendar", "events", "list", "--json", "--limit", String(limit)],
    { encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 }
  );
  return JSON.parse(output);
}

function scoreRelationships(
  emails: EmailMessage[],
  events: CalendarEvent[],
  myEmail: string
): RelationshipScore[] {
  const contacts = new Map<string, {
    sent: number; received: number; meetings: number;
    lastInteraction: Date | null; name: string; domain: string;
  }>();

  const getOrCreate = (email: string) => {
    if (!contacts.has(email)) {
      contacts.set(email, {
        sent: 0, received: 0, meetings: 0,
        lastInteraction: null, name: "", domain: email.split("@")[1],
      });
    }
    return contacts.get(email)!;
  };

  for (const msg of emails) {
    const sender = msg.from?.[0]?.email;
    if (!sender) continue;
    const msgDate = msg.date ? new Date(msg.date) : null;

    if (sender === myEmail) {
      for (const r of msg.to ?? []) {
        const c = getOrCreate(r.email);
        c.sent++;
        c.name = c.name || r.name || "";
        if (msgDate && (!c.lastInteraction || msgDate > c.lastInteraction)) {
          c.lastInteraction = msgDate;
        }
      }
    } else {
      const c = getOrCreate(sender);
      c.received++;
      c.name = c.name || msg.from[0].name || "";
      if (msgDate && (!c.lastInteraction || msgDate > c.lastInteraction)) {
        c.lastInteraction = msgDate;
      }
    }
  }

  for (const event of events) {
    for (const p of event.participants ?? []) {
      if (p.email && p.email !== myEmail) {
        getOrCreate(p.email).meetings++;
      }
    }
  }

  const now = new Date();
  const scored: RelationshipScore[] = [];

  for (const [email, data] of contacts) {
    const total = data.sent + data.received;
    if (total === 0) continue;

    const freqScore = Math.min(total / 20, 1.0) * 40;
    const daysAgo = data.lastInteraction
      ? (now.getTime() - data.lastInteraction.getTime()) / 86_400_000
      : 90;
    const recencyScore = Math.max(0, 1 - daysAgo / 90) * 25;
    const reciprocity = Math.min(data.sent, data.received)
      / Math.max(data.sent, data.received, 1);
    const recipScore = reciprocity * 20;
    const meetingScore = Math.min(data.meetings / 5, 1.0) * 15;

    const strength = Math.min(
      Math.round(freqScore + recencyScore + recipScore + meetingScore), 100
    );

    scored.push({
      email, name: data.name, domain: data.domain, strength,
      emailsSent: data.sent, emailsReceived: data.received,
      meetings: data.meetings, reciprocity: Math.round(reciprocity * 100) / 100,
    });
  }

  return scored.sort((a, b) => b.strength - a.strength);
}

function buildCompanyMap(scored: RelationshipScore[]): CompanyMap[] {
  const groups = new Map<string, RelationshipScore[]>();
  for (const c of scored) {
    if (!groups.has(c.domain)) groups.set(c.domain, []);
    groups.get(c.domain)!.push(c);
  }

  const companies: CompanyMap[] = [];
  for (const [domain, members] of groups) {
    const sorted = [...members].sort((a, b) => b.strength - a.strength);
    companies.push({
      company: domain,
      contacts: members.length,
      avgStrength: Math.round(
        members.reduce((s, m) => s + m.strength, 0) / members.length
      ),
      keyContact: sorted[0].email,
      strongest: sorted[0].strength,
      allContacts: sorted,
    });
  }

  return companies.sort((a, b) => b.avgStrength - a.avgStrength);
}

// Main
const emails = loadEmails(500);
const events = loadEvents(200);
const scored = scoreRelationships(emails, events, "you@yourcompany.com");
const companies = buildCompanyMap(scored);

writeFileSync("scored_contacts.json", JSON.stringify(scored, null, 2));
writeFileSync("company_map.json", JSON.stringify(companies, null, 2));

console.log(`Scored ${scored.length} contacts across ${companies.length} companies\n`);
console.log("Top 10 companies:");
for (const c of companies.slice(0, 10)) {
  console.log(
    `  ${c.company.padEnd(30)} ${String(c.contacts).padStart(3)} contacts  `
    + `avg strength ${String(c.avgStrength).padStart(3)}`
  );
}

Run with npx tsx score_contacts.ts. The script outputs both scored_contacts.json and company_map.json.

Next steps